diff --git a/.core_files.yaml b/.core_files.yaml index 5e9b1d50def..0817d5c8261 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -30,6 +30,7 @@ base_platforms: &base_platforms - homeassistant/components/humidifier/** - homeassistant/components/image/** - homeassistant/components/image_processing/** + - homeassistant/components/lawn_mower/** - homeassistant/components/light/** - homeassistant/components/lock/** - homeassistant/components/media_player/** @@ -54,6 +55,7 @@ base_platforms: &base_platforms components: &components - homeassistant/components/alexa/** - homeassistant/components/application_credentials/** + - homeassistant/components/assist_pipeline/** - homeassistant/components/auth/** - homeassistant/components/automation/** - homeassistant/components/backup/** @@ -86,6 +88,7 @@ components: &components - homeassistant/components/lovelace/** - homeassistant/components/media_source/** - homeassistant/components/mjpeg/** + - homeassistant/components/modbus/** - homeassistant/components/mqtt/** - homeassistant/components/network/** - homeassistant/components/onboarding/** diff --git a/.coveragerc b/.coveragerc index fb1869b2489..97ed97ef293 100644 --- a/.coveragerc +++ b/.coveragerc @@ -57,6 +57,7 @@ omit = homeassistant/components/ambiclimate/climate.py homeassistant/components/ambient_station/__init__.py homeassistant/components/ambient_station/binary_sensor.py + homeassistant/components/ambient_station/entity.py homeassistant/components/ambient_station/sensor.py homeassistant/components/amcrest/* homeassistant/components/ampio/* @@ -168,6 +169,10 @@ omit = homeassistant/components/cmus/media_player.py homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py + homeassistant/components/comelit/__init__.py + homeassistant/components/comelit/const.py + homeassistant/components/comelit/coordinator.py + homeassistant/components/comelit/light.py homeassistant/components/comfoconnect/fan.py homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py @@ -212,10 +217,12 @@ omit = homeassistant/components/dominos/* homeassistant/components/doods/* homeassistant/components/doorbird/__init__.py - homeassistant/components/doorbird/button.py homeassistant/components/doorbird/camera.py + homeassistant/components/doorbird/button.py + homeassistant/components/doorbird/device.py homeassistant/components/doorbird/entity.py homeassistant/components/doorbird/util.py + homeassistant/components/doorbird/view.py homeassistant/components/dormakaba_dkey/__init__.py homeassistant/components/dormakaba_dkey/binary_sensor.py homeassistant/components/dormakaba_dkey/entity.py @@ -234,6 +241,7 @@ omit = homeassistant/components/duotecno/entity.py homeassistant/components/duotecno/switch.py homeassistant/components/duotecno/cover.py + homeassistant/components/duotecno/light.py homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py @@ -270,6 +278,7 @@ omit = homeassistant/components/electric_kiwi/oauth2.py homeassistant/components/electric_kiwi/sensor.py homeassistant/components/electric_kiwi/coordinator.py + homeassistant/components/electric_kiwi/select.py homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/__init__.py homeassistant/components/elkm1/alarm_control_panel.py @@ -282,7 +291,8 @@ omit = homeassistant/components/elmax/alarm_control_panel.py homeassistant/components/elmax/binary_sensor.py homeassistant/components/elmax/common.py - homeassistant/components/elmax/binary_sensor.py + homeassistant/components/elmax/const.py + homeassistant/components/elmax/cover.py homeassistant/components/elmax/switch.py homeassistant/components/elv/* homeassistant/components/emby/media_player.py @@ -299,7 +309,13 @@ omit = homeassistant/components/enocean/sensor.py homeassistant/components/enocean/switch.py homeassistant/components/enphase_envoy/__init__.py + homeassistant/components/enphase_envoy/binary_sensor.py + homeassistant/components/enphase_envoy/coordinator.py + homeassistant/components/enphase_envoy/entity.py + homeassistant/components/enphase_envoy/number.py + homeassistant/components/enphase_envoy/select.py homeassistant/components/enphase_envoy/sensor.py + homeassistant/components/enphase_envoy/switch.py homeassistant/components/entur_public_transport/* homeassistant/components/environment_canada/__init__.py homeassistant/components/environment_canada/camera.py @@ -334,6 +350,7 @@ omit = homeassistant/components/ezviz/entity.py homeassistant/components/ezviz/select.py homeassistant/components/ezviz/sensor.py + homeassistant/components/ezviz/siren.py homeassistant/components/ezviz/switch.py homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py @@ -415,6 +432,7 @@ omit = homeassistant/components/garadget/cover.py homeassistant/components/garages_amsterdam/__init__.py homeassistant/components/garages_amsterdam/binary_sensor.py + homeassistant/components/garages_amsterdam/entity.py homeassistant/components/garages_amsterdam/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* @@ -708,8 +726,6 @@ omit = homeassistant/components/meteoclimatic/__init__.py homeassistant/components/meteoclimatic/sensor.py homeassistant/components/meteoclimatic/weather.py - homeassistant/components/metoffice/sensor.py - homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py @@ -764,10 +780,12 @@ omit = homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py homeassistant/components/neato/camera.py + homeassistant/components/neato/entity.py homeassistant/components/neato/hub.py homeassistant/components/neato/sensor.py homeassistant/components/neato/switch.py homeassistant/components/neato/vacuum.py + homeassistant/components/neato/button.py homeassistant/components/nederlandse_spoorwegen/sensor.py homeassistant/components/netdata/sensor.py homeassistant/components/netgear/__init__.py @@ -859,7 +877,6 @@ omit = homeassistant/components/openhome/const.py homeassistant/components/openhome/media_player.py homeassistant/components/opensensemap/air_quality.py - homeassistant/components/opensky/sensor.py homeassistant/components/opentherm_gw/__init__.py homeassistant/components/opentherm_gw/binary_sensor.py homeassistant/components/opentherm_gw/climate.py @@ -989,6 +1006,7 @@ omit = homeassistant/components/renson/const.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py + homeassistant/components/renson/binary_sensor.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py @@ -1167,7 +1185,13 @@ omit = homeassistant/components/squeezebox/__init__.py homeassistant/components/squeezebox/browse_media.py homeassistant/components/squeezebox/media_player.py + homeassistant/components/starlink/__init__.py + homeassistant/components/starlink/binary_sensor.py + homeassistant/components/starlink/button.py homeassistant/components/starlink/coordinator.py + homeassistant/components/starlink/device_tracker.py + homeassistant/components/starlink/sensor.py + homeassistant/components/starlink/switch.py homeassistant/components/starline/__init__.py homeassistant/components/starline/account.py homeassistant/components/starline/binary_sensor.py @@ -1311,9 +1335,6 @@ omit = homeassistant/components/tplink_omada/__init__.py homeassistant/components/tplink_omada/binary_sensor.py homeassistant/components/tplink_omada/controller.py - homeassistant/components/tplink_omada/coordinator.py - homeassistant/components/tplink_omada/entity.py - homeassistant/components/tplink_omada/switch.py homeassistant/components/tplink_omada/update.py homeassistant/components/traccar/device_tracker.py homeassistant/components/tractive/__init__.py @@ -1333,9 +1354,11 @@ omit = homeassistant/components/trafikverket_train/__init__.py homeassistant/components/trafikverket_train/coordinator.py homeassistant/components/trafikverket_train/sensor.py + homeassistant/components/trafikverket_train/util.py homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/coordinator.py homeassistant/components/trafikverket_weatherstation/sensor.py + homeassistant/components/transmission/__init__.py homeassistant/components/transmission/sensor.py homeassistant/components/transmission/switch.py homeassistant/components/travisci/sensor.py @@ -1418,6 +1441,10 @@ omit = homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/__init__.py homeassistant/components/vlc_telnet/media_player.py + homeassistant/components/vodafone_station/__init__.py + homeassistant/components/vodafone_station/const.py + homeassistant/components/vodafone_station/coordinator.py + homeassistant/components/vodafone_station/device_tracker.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/__init__.py homeassistant/components/volumio/browse_media.py @@ -1487,7 +1514,6 @@ omit = homeassistant/components/yale_smart_alarm/alarm_control_panel.py homeassistant/components/yale_smart_alarm/binary_sensor.py homeassistant/components/yale_smart_alarm/button.py - homeassistant/components/yale_smart_alarm/coordinator.py homeassistant/components/yale_smart_alarm/entity.py homeassistant/components/yale_smart_alarm/lock.py homeassistant/components/yalexs_ble/__init__.py @@ -1502,6 +1528,9 @@ omit = homeassistant/components/yamaha_musiccast/select.py homeassistant/components/yamaha_musiccast/switch.py homeassistant/components/yandex_transport/sensor.py + homeassistant/components/yardian/__init__.py + homeassistant/components/yardian/coordinator.py + homeassistant/components/yardian/switch.py homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py homeassistant/components/yolink/__init__.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 47e3e765b72..3296f33f84c 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 with: fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -197,7 +197,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.06.1 + uses: home-assistant/builder@2023.08.0 with: args: | $BUILD_ARGS \ @@ -251,9 +251,10 @@ jobs: - raspberrypi4-64 - tinker - yellow + - green steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set build additional args run: | @@ -274,7 +275,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2023.06.1 + uses: home-assistant/builder@2023.08.0 with: args: | $BUILD_ARGS \ @@ -292,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -330,7 +331,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4561e8a53e1..26811f31962 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,6 +19,10 @@ on: description: "Skip pytest" default: false type: boolean + skip-coverage: + description: "Skip coverage" + default: false + type: boolean pylint-only: description: "Only run pylint" default: false @@ -32,7 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.8 + HA_SHORT_VERSION: 2023.9 DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" # 10.3 is the oldest supported version @@ -79,10 +83,11 @@ jobs: test_groups: ${{ steps.info.outputs.test_groups }} tests_glob: ${{ steps.info.outputs.tests_glob }} tests: ${{ steps.info.outputs.tests }} + skip_coverage: ${{ steps.info.outputs.skip_coverage }} runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -127,6 +132,7 @@ jobs: test_group_count=10 tests="[]" tests_glob="" + skip_coverage="" if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]]; then @@ -176,6 +182,12 @@ jobs: test_full_suite="true" fi + if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \ + || [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]]; + then + skip_coverage="true" + fi + # Output & sent to GitHub Actions echo "mariadb_groups: ${mariadb_groups}" echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT @@ -195,6 +207,8 @@ jobs: echo "tests=${tests}" >> $GITHUB_OUTPUT echo "tests_glob: ${tests_glob}" echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT + echo "skip_coverage: ${skip_coverage}" + echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT pre-commit: name: Prepare pre-commit base @@ -206,7 +220,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -251,7 +265,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -297,7 +311,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -346,7 +360,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -440,7 +454,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -508,7 +522,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -540,7 +554,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -573,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -617,7 +631,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -699,7 +713,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -734,9 +748,19 @@ jobs: - name: Run pytest (fully) if: needs.info.outputs.test_full_suite == 'true' timeout-minutes: 60 + id: pytest-full + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version + set -o pipefail + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant") + cov_params+=(--cov-report=xml) + fi + python3 -X dev -m pytest \ -qq \ --timeout=9 \ @@ -745,37 +769,54 @@ jobs: --dist=loadfile \ --test-group-count ${{ needs.info.outputs.test_group_count }} \ --test-group=${{ matrix.group }} \ - --cov="homeassistant" \ - --cov-report=xml \ + ${cov_params[@]} \ -o console_output_style=count \ -p no:sugar \ - tests + tests \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Run pytest (partially) if: needs.info.outputs.test_full_suite == 'false' timeout-minutes: 10 + id: pytest-partial shell: bash + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version + set -o pipefail if [[ ! -f "tests/components/${{ matrix.group }}/__init__.py" ]]; then echo "::error:: missing file tests/components/${{ matrix.group }}/__init__.py" exit 1 fi + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant.components.${{ matrix.group }}") + cov_params+=(--cov-report=xml) + cov_params+=(--cov-report=term-missing) + fi + python3 -X dev -m pytest \ -qq \ --timeout=9 \ -n auto \ - --cov="homeassistant.components.${{ matrix.group }}" \ - --cov-report=xml \ - --cov-report=term-missing \ + ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ --durations-min=1 \ -p no:sugar \ - tests/components/${{ matrix.group }} + tests/components/${{ matrix.group }} \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt + - name: Upload pytest output + if: success() || failure() && (steps.pytest-full.conclusion == 'failure' || steps.pytest-partial.conclusion == 'failure') + uses: actions/upload-artifact@v3.1.2 + with: + name: pytest-${{ github.run_number }} + path: pytest-*.txt - name: Upload coverage artifact + if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} @@ -824,7 +865,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -862,18 +903,27 @@ jobs: python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 + id: pytest-partial shell: bash + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version + set -o pipefail + mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g") + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant.components.recorder") + cov_params+=(--cov-report=xml) + cov_params+=(--cov-report=term-missing) + fi python3 -X dev -m pytest \ -qq \ --timeout=20 \ -n 1 \ - --cov="homeassistant.components.recorder" \ - --cov-report=xml \ - --cov-report=term-missing \ + ${cov_params[@]} \ -o console_output_style=count \ --durations=10 \ -p no:sugar \ @@ -881,8 +931,16 @@ jobs: tests/components/history \ tests/components/logbook \ tests/components/recorder \ - tests/components/sensor + tests/components/sensor \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt + - name: Upload pytest output + if: success() || failure() && steps.pytest-partial.conclusion == 'failure' + uses: actions/upload-artifact@v3.1.2 + with: + name: pytest-${{ github.run_number }} + path: pytest-*.txt - name: Upload coverage artifact + if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.2 with: name: coverage-${{ matrix.python-version }}-mariadb @@ -931,7 +989,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -969,18 +1027,27 @@ jobs: python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 + id: pytest-partial shell: bash + env: + PYTHONDONTWRITEBYTECODE: 1 run: | . venv/bin/activate python --version + set -o pipefail + postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g") + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant.components.recorder") + cov_params+=(--cov-report=xml) + cov_params+=(--cov-report=term-missing) + fi python3 -X dev -m pytest \ -qq \ --timeout=9 \ -n 1 \ - --cov="homeassistant.components.recorder" \ - --cov-report=xml \ - --cov-report=term-missing \ + ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ --durations-min=10 \ @@ -989,8 +1056,16 @@ jobs: tests/components/history \ tests/components/logbook \ tests/components/recorder \ - tests/components/sensor + tests/components/sensor \ + 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt + - name: Upload pytest output + if: success() || failure() && steps.pytest-partial.conclusion == 'failure' + uses: actions/upload-artifact@v3.1.2 + with: + name: pytest-${{ github.run_number }} + path: pytest-*.txt - name: Upload coverage artifact + if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.0 with: name: coverage-${{ matrix.python-version }}-postgresql @@ -1001,6 +1076,7 @@ jobs: coverage: name: Upload test coverage to Codecov + if: needs.info.outputs.skip_coverage != 'true' runs-on: ubuntu-22.04 needs: - info @@ -1008,7 +1084,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 1d77ac8f130..5affa459f52 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 16bd347d7cf..01823199c17 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Get information id: info @@ -84,7 +84,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -122,7 +122,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v3.6.0 - name: Download env_file uses: actions/download-artifact@v3 diff --git a/.gitignore b/.gitignore index 2f3c3e10301..ff20c088eb2 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ htmlcov/ test-reports/ test-results.xml test-output.xml +pytest-*.txt # Translations *.mo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7b351b755f..77740d6279e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.280 + rev: v0.0.285 hooks: - id: ruff args: - --fix - - repo: https://github.com/psf/black + - repo: https://github.com/psf/black-pre-commit-mirror rev: 23.7.0 hooks: - id: black @@ -43,7 +43,7 @@ repos: hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update - rev: v0.5.0 + rev: v0.6.0 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. @@ -52,7 +52,7 @@ repos: - id: python-typing-update stages: [manual] args: - - --py310-plus + - --py311-plus - --force - --keep-updates files: ^(homeassistant|tests|script)/.+\.py$ diff --git a/.strict-typing b/.strict-typing index dffeb08e014..e8bca0a1abd 100644 --- a/.strict-typing +++ b/.strict-typing @@ -53,6 +53,7 @@ homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* +homeassistant.components.alexa.* homeassistant.components.amazon_polly.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* @@ -103,6 +104,7 @@ homeassistant.components.dhcp.* homeassistant.components.diagnostics.* homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* +homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* @@ -147,6 +149,7 @@ homeassistant.components.history.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* +homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_hardware.* homeassistant.components.homeassistant_sky_connect.* homeassistant.components.homeassistant_yellow.* @@ -181,6 +184,7 @@ homeassistant.components.imap.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.integration.* +homeassistant.components.ipp.* homeassistant.components.iqvia.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* @@ -193,6 +197,7 @@ homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* homeassistant.components.lametric.* homeassistant.components.laundrify.* +homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* @@ -209,6 +214,7 @@ homeassistant.components.luftdaten.* homeassistant.components.mailbox.* homeassistant.components.mastodon.* homeassistant.components.matter.* +homeassistant.components.media_extractor.* homeassistant.components.media_player.* homeassistant.components.media_source.* homeassistant.components.metoffice.* @@ -296,6 +302,7 @@ homeassistant.components.sonarr.* homeassistant.components.speedtestdotnet.* homeassistant.components.sql.* homeassistant.components.ssdp.* +homeassistant.components.starlink.* homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* @@ -321,6 +328,7 @@ homeassistant.components.tplink.* homeassistant.components.tplink_omada.* homeassistant.components.tractive.* homeassistant.components.tradfri.* +homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* diff --git a/CODEOWNERS b/CODEOWNERS index 10acd5dd65a..2d28671fce5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,8 +19,6 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza -/homeassistant/const.py @epenet -/homeassistant/util/ @epenet # Integrations /homeassistant/components/abode/ @shred86 @@ -51,8 +49,6 @@ build.json @home-assistant/supervisor /tests/components/airthings/ @danielhiversen /homeassistant/components/airthings_ble/ @vincegio /tests/components/airthings_ble/ @vincegio -/homeassistant/components/airtouch4/ @LonePurpleWolf -/tests/components/airtouch4/ @LonePurpleWolf /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya @@ -211,6 +207,8 @@ build.json @home-assistant/supervisor /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent /tests/components/color_extractor/ @GenericStudent +/homeassistant/components/comelit/ @chemelli74 +/tests/components/comelit/ @chemelli74 /homeassistant/components/comfoconnect/ @michaelarnauts /tests/components/comfoconnect/ @michaelarnauts /homeassistant/components/command_line/ @gjohansson-ST @@ -295,12 +293,10 @@ build.json @home-assistant/supervisor /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox /tests/components/dsmr_reader/ @depl0y @glodenox -/homeassistant/components/dunehd/ @bieniu -/tests/components/dunehd/ @bieniu /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo -/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k @@ -345,8 +341,8 @@ build.json @home-assistant/supervisor /homeassistant/components/enigma2/ @fbradyirl /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer -/homeassistant/components/enphase_envoy/ @gtdiehl -/tests/components/enphase_envoy/ @gtdiehl +/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek +/tests/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek /homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie @@ -525,6 +521,8 @@ build.json @home-assistant/supervisor /tests/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant_alerts/ @home-assistant/core /tests/components/homeassistant_alerts/ @home-assistant/core +/homeassistant/components/homeassistant_green/ @home-assistant/core +/tests/components/homeassistant_green/ @home-assistant/core /homeassistant/components/homeassistant_hardware/ @home-assistant/core /tests/components/homeassistant_hardware/ @home-assistant/core /homeassistant/components/homeassistant_sky_connect/ @home-assistant/core @@ -608,8 +606,8 @@ build.json @home-assistant/supervisor /homeassistant/components/iotawatt/ @gtdiehl @jyavenard /tests/components/iotawatt/ @gtdiehl @jyavenard /homeassistant/components/iperf3/ @rohankapoorcom -/homeassistant/components/ipma/ @dgomes @abmantis -/tests/components/ipma/ @dgomes @abmantis +/homeassistant/components/ipma/ @dgomes +/tests/components/ipma/ @dgomes /homeassistant/components/ipp/ @ctalkington /tests/components/ipp/ @ctalkington /homeassistant/components/iqvia/ @bachya @@ -673,6 +671,8 @@ build.json @home-assistant/supervisor /tests/components/launch_library/ @ludeeus @DurgNomis-drol /homeassistant/components/laundrify/ @xLarry /tests/components/laundrify/ @xLarry +/homeassistant/components/lawn_mower/ @home-assistant/core +/tests/components/lawn_mower/ @home-assistant/core /homeassistant/components/lcn/ @alengwenus /tests/components/lcn/ @alengwenus /homeassistant/components/ld2410_ble/ @930913 @@ -729,6 +729,7 @@ build.json @home-assistant/supervisor /tests/components/mazda/ @bdr99 /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery +/homeassistant/components/media_extractor/ @joostlek /homeassistant/components/media_player/ @home-assistant/core /tests/components/media_player/ @home-assistant/core /homeassistant/components/media_source/ @hunterjm @@ -1028,8 +1029,6 @@ build.json @home-assistant/supervisor /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core /homeassistant/components/repetier/ @MTrab @ShadowBr0ther -/homeassistant/components/rest/ @epenet -/tests/components/rest/ @epenet /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 @@ -1058,8 +1057,8 @@ build.json @home-assistant/supervisor /tests/components/rss_feed_template/ @home-assistant/core /homeassistant/components/rtsp_to_webrtc/ @allenporter /tests/components/rtsp_to_webrtc/ @allenporter -/homeassistant/components/ruckus_unleashed/ @gabe565 -/tests/components/ruckus_unleashed/ @gabe565 +/homeassistant/components/ruckus_unleashed/ @gabe565 @lanrat +/tests/components/ruckus_unleashed/ @gabe565 @lanrat /homeassistant/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx @@ -1077,9 +1076,11 @@ build.json @home-assistant/supervisor /tests/components/scene/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core /tests/components/schedule/ @home-assistant/core +/homeassistant/components/schlage/ @dknowles2 +/tests/components/schlage/ @dknowles2 /homeassistant/components/schluter/ @prairieapps -/homeassistant/components/scrape/ @fabaff @gjohansson-ST @epenet -/tests/components/scrape/ @fabaff @gjohansson-ST @epenet +/homeassistant/components/scrape/ @fabaff @gjohansson-ST +/tests/components/scrape/ @fabaff @gjohansson-ST /homeassistant/components/screenlogic/ @dieselrabbit @bdraco /tests/components/screenlogic/ @dieselrabbit @bdraco /homeassistant/components/script/ @home-assistant/core @@ -1226,8 +1227,8 @@ build.json @home-assistant/supervisor /tests/components/switch_as_x/ @home-assistant/core /homeassistant/components/switchbee/ @jafar-atili /tests/components/switchbee/ @jafar-atili -/homeassistant/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski -/tests/components/switchbot/ @bdraco @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski +/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski +/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /homeassistant/components/switcher_kis/ @thecode /tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li @@ -1298,6 +1299,8 @@ build.json @home-assistant/supervisor /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu /tests/components/tractive/ @Danielhiversen @zhulik @bieniu +/homeassistant/components/trafikverket_camera/ @gjohansson-ST +/tests/components/trafikverket_camera/ @gjohansson-ST /homeassistant/components/trafikverket_ferry/ @gjohansson-ST /tests/components/trafikverket_ferry/ @gjohansson-ST /homeassistant/components/trafikverket_train/ @endor-force @gjohansson-ST @@ -1365,6 +1368,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare +/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74 +/tests/components/vodafone_station/ @paoloantinori @chemelli74 /homeassistant/components/voip/ @balloob @synesthesiam /tests/components/voip/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund @@ -1375,6 +1380,8 @@ build.json @home-assistant/supervisor /tests/components/vulcan/ @Antoni-Czaplicki /homeassistant/components/wake_on_lan/ @ntilley905 /tests/components/wake_on_lan/ @ntilley905 +/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam +/tests/components/wake_word/ @home-assistant/core @synesthesiam /homeassistant/components/wallbox/ @hesselonline /tests/components/wallbox/ @hesselonline /homeassistant/components/waqi/ @andrey-git @@ -1438,6 +1445,7 @@ build.json @home-assistant/supervisor /tests/components/yamaha_musiccast/ @vigonotion @micha91 /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis +/homeassistant/components/yardian/ @h3l1o5 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward diff --git a/build.yaml b/build.yaml index 882fa31f121..cc13a4e595f 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.07.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.07.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.07.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.07.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.07.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.08.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.08.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.08.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.08.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.08.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index f7ba18d3d75..9e4afa018a6 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -148,16 +148,6 @@ def get_arguments() -> argparse.Namespace: return arguments -def cmdline() -> list[str]: - """Collect path and arguments to re-execute the current hass instance.""" - if os.path.basename(sys.argv[0]) == "__main__.py": - modulepath = os.path.dirname(sys.argv[0]) - os.environ["PYTHONPATH"] = os.path.dirname(modulepath) - return [sys.executable, "-m", "homeassistant"] + list(sys.argv[1:]) - - return sys.argv - - def check_threads() -> None: """Check if there are any lingering threads.""" try: diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index 83d66a39f71..212c8516b48 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -8,7 +8,7 @@ from typing import Any, Generic, Self, TypeVar, overload _T = TypeVar("_T") -class cached_property(Generic[_T]): # pylint: disable=invalid-name +class cached_property(Generic[_T]): """Backport of Python 3.12's cached_property. Includes https://github.com/python/cpython/pull/101890/files diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 6a667884962..81ae4eb6e18 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -110,8 +110,7 @@ async def async_setup_hass( runtime_config: RuntimeConfig, ) -> core.HomeAssistant | None: """Set up Home Assistant.""" - hass = core.HomeAssistant() - hass.config.config_dir = runtime_config.config_dir + hass = core.HomeAssistant(runtime_config.config_dir) async_enable_logging( hass, @@ -134,6 +133,7 @@ async def async_setup_hass( _LOGGER.info("Config directory: %s", runtime_config.config_dir) + loader.async_setup(hass) config_dict = None basic_setup_success = False @@ -177,14 +177,15 @@ async def async_setup_hass( old_config = hass.config old_logging = hass.data.get(DATA_LOGGING) - hass = core.HomeAssistant() + hass = core.HomeAssistant(old_config.config_dir) if old_logging: hass.data[DATA_LOGGING] = old_logging hass.config.skip_pip = old_config.skip_pip hass.config.skip_pip_packages = old_config.skip_pip_packages hass.config.internal_url = old_config.internal_url hass.config.external_url = old_config.external_url - hass.config.config_dir = old_config.config_dir + # Setup loader cache after the config dir has been set + loader.async_setup(hass) if safe_mode: _LOGGER.info("Starting in safe mode") diff --git a/homeassistant/brands/trafikverket.json b/homeassistant/brands/trafikverket.json index df444cbeb60..4b925d5c633 100644 --- a/homeassistant/brands/trafikverket.json +++ b/homeassistant/brands/trafikverket.json @@ -2,6 +2,7 @@ "domain": "trafikverket", "name": "Trafikverket", "integrations": [ + "trafikverket_camera", "trafikverket_ferry", "trafikverket_train", "trafikverket_weatherstation" diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 38e88944867..490561c7485 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -28,6 +28,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import dispatcher_send from .const import ATTRIBUTION, CONF_POLLING, DOMAIN, LOGGER @@ -287,14 +288,14 @@ class AbodeDevice(AbodeEntity): """Initialize Abode device.""" super().__init__(data) self._device = device - self._attr_unique_id = device.device_uuid + self._attr_unique_id = device.uuid async def async_added_to_hass(self) -> None: """Subscribe to device events.""" await super().async_added_to_hass() await self.hass.async_add_executor_job( self._data.abode.events.add_device_callback, - self._device.device_id, + self._device.id, self._update_callback, ) @@ -302,7 +303,7 @@ class AbodeDevice(AbodeEntity): """Unsubscribe from device events.""" await super().async_will_remove_from_hass() await self.hass.async_add_executor_job( - self._data.abode.events.remove_all_device_callbacks, self._device.device_id + self._data.abode.events.remove_all_device_callbacks, self._device.id ) def update(self) -> None: @@ -313,17 +314,17 @@ class AbodeDevice(AbodeEntity): def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return { - "device_id": self._device.device_id, + "device_id": self._device.id, "battery_low": self._device.battery_low, "no_response": self._device.no_response, "device_type": self._device.type, } @property - def device_info(self) -> entity.DeviceInfo: + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" - return entity.DeviceInfo( - identifiers={(DOMAIN, self._device.device_id)}, + return DeviceInfo( + identifiers={(DOMAIN, self._device.id)}, manufacturer="Abode", model=self._device.type, name=self._device.name, diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 66a2e3b0db5..d0137395446 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -69,7 +69,7 @@ class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return { - "device_id": self._device.device_id, + "device_id": self._device.id, "battery_backup": self._device.battery, "cellular_backup": self._device.is_cellular, } diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index afe017bfcc7..326e845b16b 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -30,7 +30,7 @@ async def async_setup_entry( data: AbodeSystem = hass.data[DOMAIN] async_add_entities( - AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE) # pylint: disable=no-member + AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE) for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA) ) diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index 1e238783221..bceed215428 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -1,6 +1,8 @@ """Support for Abode Security System sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import cast from jaraco.abode.devices.sensor import Sensor as AbodeSense @@ -12,25 +14,52 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LIGHT_LUX +from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AbodeDevice, AbodeSystem from .const import DOMAIN -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +ABODE_TEMPERATURE_UNIT_HA_UNIT = { + CONST.UNIT_FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + CONST.UNIT_CELSIUS: UnitOfTemperature.CELSIUS, +} + + +@dataclass +class AbodeSensorDescriptionMixin: + """Mixin for Abode sensor.""" + + value_fn: Callable[[AbodeSense], float] + native_unit_of_measurement_fn: Callable[[AbodeSense], str] + + +@dataclass +class AbodeSensorDescription(SensorEntityDescription, AbodeSensorDescriptionMixin): + """Class describing Abode sensor entities.""" + + +SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = ( + AbodeSensorDescription( key=CONST.TEMP_STATUS_KEY, device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[ + device.temp_unit + ], + value_fn=lambda device: cast(float, device.temp), ), - SensorEntityDescription( + AbodeSensorDescription( key=CONST.HUMI_STATUS_KEY, device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement_fn=lambda _: PERCENTAGE, + value_fn=lambda device: cast(float, device.humidity), ), - SensorEntityDescription( + AbodeSensorDescription( key=CONST.LUX_STATUS_KEY, device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement_fn=lambda _: LIGHT_LUX, + value_fn=lambda device: cast(float, device.lux), ), ) @@ -52,32 +81,26 @@ async def async_setup_entry( class AbodeSensor(AbodeDevice, SensorEntity): """A sensor implementation for Abode devices.""" + entity_description: AbodeSensorDescription _device: AbodeSense def __init__( self, data: AbodeSystem, device: AbodeSense, - description: SensorEntityDescription, + description: AbodeSensorDescription, ) -> None: """Initialize a sensor for an Abode device.""" super().__init__(data, device) self.entity_description = description - self._attr_unique_id = f"{device.device_uuid}-{description.key}" - if description.key == CONST.TEMP_STATUS_KEY: - self._attr_native_unit_of_measurement = device.temp_unit - elif description.key == CONST.HUMI_STATUS_KEY: - self._attr_native_unit_of_measurement = device.humidity_unit - elif description.key == CONST.LUX_STATUS_KEY: - self._attr_native_unit_of_measurement = LIGHT_LUX + self._attr_unique_id = f"{device.uuid}-{description.key}" @property - def native_value(self) -> float | None: + def native_value(self) -> float: """Return the state of the sensor.""" - if self.entity_description.key == CONST.TEMP_STATUS_KEY: - return cast(float, self._device.temp) - if self.entity_description.key == CONST.HUMI_STATUS_KEY: - return cast(float, self._device.humidity) - if self.entity_description.key == CONST.LUX_STATUS_KEY: - return cast(float, self._device.lux) - return None + return self.entity_description.value_fn(self._device) + + @property + def native_unit_of_measurement(self) -> str: + """Return the native unit of measurement.""" + return self.entity_description.native_unit_of_measurement_fn(self._device) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 2a19f0d0291..e98b19e8e82 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -1,6 +1,7 @@ """The AccuWeather component.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from typing import Any @@ -8,7 +9,6 @@ from typing import Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -16,8 +16,7 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index 1480f6c1352..b1d113dad73 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio +from asyncio import timeout from typing import Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout import voluptuous as vol from homeassistant import config_entries diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 87bc8eaef89..2e18977d112 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -50,3 +50,8 @@ CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_SUNNY: [1, 2, 5], ATTR_CONDITION_WINDY: [32], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 9eca5e772b0..c983f0bc291 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -59,193 +59,280 @@ class AccuWeatherSensorDescription( """Class describing AccuWeather sensor entities.""" attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {} + day: int | None = None FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( - AccuWeatherSensorDescription( - key="AirQuality", - icon="mdi:air-filter", - name="Air quality", - value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), - device_class=SensorDeviceClass.ENUM, - options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], - translation_key="air_quality", + *( + AccuWeatherSensorDescription( + key="AirQuality", + icon="mdi:air-filter", + value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), + device_class=SensorDeviceClass.ENUM, + options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], + translation_key=f"air_quality_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="CloudCoverDay", - icon="mdi:weather-cloudy", - name="Cloud cover day", - entity_registry_enabled_default=False, - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), + *( + AccuWeatherSensorDescription( + key="CloudCoverDay", + icon="mdi:weather-cloudy", + entity_registry_enabled_default=False, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key=f"cloud_cover_day_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="CloudCoverNight", - icon="mdi:weather-cloudy", - name="Cloud cover night", - entity_registry_enabled_default=False, - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), + *( + AccuWeatherSensorDescription( + key="CloudCoverNight", + icon="mdi:weather-cloudy", + entity_registry_enabled_default=False, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key=f"cloud_cover_night_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="Grass", - icon="mdi:grass", - name="Grass pollen", - entity_registry_enabled_default=False, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key="grass_pollen", + *( + AccuWeatherSensorDescription( + key="Grass", + icon="mdi:grass", + entity_registry_enabled_default=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key=f"grass_pollen_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="HoursOfSun", - icon="mdi:weather-partly-cloudy", - name="Hours of sun", - native_unit_of_measurement=UnitOfTime.HOURS, - value_fn=lambda data: cast(float, data), + *( + AccuWeatherSensorDescription( + key="HoursOfSun", + icon="mdi:weather-partly-cloudy", + native_unit_of_measurement=UnitOfTime.HOURS, + value_fn=lambda data: cast(float, data), + translation_key=f"hours_of_sun_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="LongPhraseDay", - name="Condition day", - value_fn=lambda data: cast(str, data), + *( + AccuWeatherSensorDescription( + key="LongPhraseDay", + value_fn=lambda data: cast(str, data), + translation_key=f"condition_day_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="LongPhraseNight", - name="Condition night", - value_fn=lambda data: cast(str, data), + *( + AccuWeatherSensorDescription( + key="LongPhraseNight", + value_fn=lambda data: cast(str, data), + translation_key=f"condition_night_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="Mold", - icon="mdi:blur", - name="Mold pollen", - entity_registry_enabled_default=False, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key="mold_pollen", + *( + AccuWeatherSensorDescription( + key="Mold", + icon="mdi:blur", + entity_registry_enabled_default=False, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key=f"mold_pollen_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="Ragweed", - icon="mdi:sprout", - name="Ragweed pollen", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - entity_registry_enabled_default=False, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key="ragweed_pollen", + *( + AccuWeatherSensorDescription( + key="Ragweed", + icon="mdi:sprout", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key=f"ragweed_pollen_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="RealFeelTemperatureMax", - device_class=SensorDeviceClass.TEMPERATURE, - name="RealFeel temperature max", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), + *( + AccuWeatherSensorDescription( + key="RealFeelTemperatureMax", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key=f"realfeel_temperature_max_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="RealFeelTemperatureMin", - device_class=SensorDeviceClass.TEMPERATURE, - name="RealFeel temperature min", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), + *( + AccuWeatherSensorDescription( + key="RealFeelTemperatureMin", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key=f"realfeel_temperature_min_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="RealFeelTemperatureShadeMax", - device_class=SensorDeviceClass.TEMPERATURE, - name="RealFeel temperature shade max", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), + *( + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMax", + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key=f"realfeel_temperature_shade_max_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="RealFeelTemperatureShadeMin", - device_class=SensorDeviceClass.TEMPERATURE, - name="RealFeel temperature shade min", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), + *( + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMin", + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key=f"realfeel_temperature_shade_min_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="SolarIrradianceDay", - icon="mdi:weather-sunny", - name="Solar irradiance day", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), + *( + AccuWeatherSensorDescription( + key="SolarIrradianceDay", + icon="mdi:weather-sunny", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key=f"solar_irradiance_day_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="SolarIrradianceNight", - icon="mdi:weather-sunny", - name="Solar irradiance night", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, - value_fn=lambda data: cast(float, data[ATTR_VALUE]), + *( + AccuWeatherSensorDescription( + key="SolarIrradianceNight", + icon="mdi:weather-sunny", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + value_fn=lambda data: cast(float, data[ATTR_VALUE]), + translation_key=f"solar_irradiance_night_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="ThunderstormProbabilityDay", - icon="mdi:weather-lightning", - name="Thunderstorm probability day", - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), + *( + AccuWeatherSensorDescription( + key="ThunderstormProbabilityDay", + icon="mdi:weather-lightning", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key=f"thunderstorm_probability_day_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="ThunderstormProbabilityNight", - icon="mdi:weather-lightning", - name="Thunderstorm probability night", - native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(int, data), + *( + AccuWeatherSensorDescription( + key="ThunderstormProbabilityNight", + icon="mdi:weather-lightning", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key=f"thunderstorm_probability_night_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="Tree", - icon="mdi:tree-outline", - name="Tree pollen", - native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, - entity_registry_enabled_default=False, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key="tree_pollen", + *( + AccuWeatherSensorDescription( + key="Tree", + icon="mdi:tree-outline", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key=f"tree_pollen_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="UVIndex", - icon="mdi:weather-sunny", - name="UV index", - native_unit_of_measurement=UV_INDEX, - value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, - translation_key="uv_index", + *( + AccuWeatherSensorDescription( + key="UVIndex", + icon="mdi:weather-sunny", + native_unit_of_measurement=UV_INDEX, + value_fn=lambda data: cast(int, data[ATTR_VALUE]), + attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + translation_key=f"uv_index_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="WindGustDay", - device_class=SensorDeviceClass.WIND_SPEED, - name="Wind gust day", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + *( + AccuWeatherSensorDescription( + key="WindGustDay", + device_class=SensorDeviceClass.WIND_SPEED, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key=f"wind_gust_speed_day_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="WindGustNight", - device_class=SensorDeviceClass.WIND_SPEED, - name="Wind gust night", - entity_registry_enabled_default=False, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + *( + AccuWeatherSensorDescription( + key="WindGustNight", + device_class=SensorDeviceClass.WIND_SPEED, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key=f"wind_gust_speed_night_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="WindDay", - device_class=SensorDeviceClass.WIND_SPEED, - name="Wind day", - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + *( + AccuWeatherSensorDescription( + key="WindDay", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key=f"wind_speed_day_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), - AccuWeatherSensorDescription( - key="WindNight", - device_class=SensorDeviceClass.WIND_SPEED, - name="Wind night", - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), - attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + *( + AccuWeatherSensorDescription( + key="WindNight", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + value_fn=lambda data: cast(float, data[ATTR_SPEED][ATTR_VALUE]), + attr_fn=lambda data: {"direction": data[ATTR_DIRECTION][ATTR_ENGLISH]}, + translation_key=f"wind_speed_night_{day}d", + day=day, + ) + for day in range(MAX_FORECAST_DAYS + 1) ), ) @@ -253,118 +340,117 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( AccuWeatherSensorDescription( key="ApparentTemperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Apparent temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="apparent_temperature", ), AccuWeatherSensorDescription( key="Ceiling", device_class=SensorDeviceClass.DISTANCE, icon="mdi:weather-fog", - name="Cloud ceiling", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.METERS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), suggested_display_precision=0, + translation_key="cloud_ceiling", ), AccuWeatherSensorDescription( key="CloudCover", icon="mdi:weather-cloudy", - name="Cloud cover", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: cast(int, data), + translation_key="cloud_cover", ), AccuWeatherSensorDescription( key="DewPoint", device_class=SensorDeviceClass.TEMPERATURE, - name="Dew point", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="dew_point", ), AccuWeatherSensorDescription( key="RealFeelTemperature", device_class=SensorDeviceClass.TEMPERATURE, - name="RealFeel temperature", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="realfeel_temperature", ), AccuWeatherSensorDescription( key="RealFeelTemperatureShade", device_class=SensorDeviceClass.TEMPERATURE, - name="RealFeel temperature shade", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="realfeel_temperature_shade", ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, - name="Precipitation", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), attr_fn=lambda data: {"type": data["PrecipitationType"]}, + translation_key="precipitation", ), AccuWeatherSensorDescription( key="PressureTendency", device_class=SensorDeviceClass.ENUM, icon="mdi:gauge", - name="Pressure tendency", options=["falling", "rising", "steady"], - translation_key="pressure_tendency", value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), + translation_key="pressure_tendency", ), AccuWeatherSensorDescription( key="UVIndex", icon="mdi:weather-sunny", - name="UV index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, + translation_key="uv_index", ), AccuWeatherSensorDescription( key="WetBulbTemperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Wet bulb temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="wet_bulb_temperature", ), AccuWeatherSensorDescription( key="WindChillTemperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Wind chill temperature", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="wind_chill_temperature", ), AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, - name="Wind", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), + translation_key="wind_speed", ), AccuWeatherSensorDescription( key="WindGust", device_class=SensorDeviceClass.WIND_SPEED, - name="Wind gust", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), + translation_key="wind_gust_speed", ), ) @@ -381,14 +467,12 @@ async def async_setup_entry( ] if coordinator.forecast: - # Some air quality/allergy sensors are only available for certain - # locations. - sensors.extend( - AccuWeatherSensor(coordinator, description, forecast_day=day) - for day in range(MAX_FORECAST_DAYS + 1) - for description in FORECAST_SENSOR_TYPES - if description.key in coordinator.data[ATTR_FORECAST][0] - ) + for description in FORECAST_SENSOR_TYPES: + # Some air quality/allergy sensors are only available for certain + # locations. + if description.key not in coordinator.data[ATTR_FORECAST][description.day]: + continue + sensors.append(AccuWeatherSensor(coordinator, description)) async_add_entities(sensors) @@ -406,25 +490,21 @@ class AccuWeatherSensor( self, coordinator: AccuWeatherDataUpdateCoordinator, description: AccuWeatherSensorDescription, - forecast_day: int | None = None, ) -> None: """Initialize.""" super().__init__(coordinator) + self.forecast_day = description.day self.entity_description = description self._sensor_data = _get_sensor_data( - coordinator.data, description.key, forecast_day + coordinator.data, description.key, self.forecast_day ) - if forecast_day is not None: - self._attr_name = f"{description.name} {forecast_day}d" - self._attr_unique_id = ( - f"{coordinator.location_key}-{description.key}-{forecast_day}".lower() - ) + if self.forecast_day is not None: + self._attr_unique_id = f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower() else: self._attr_unique_id = ( f"{coordinator.location_key}-{description.key}".lower() ) self._attr_device_info = coordinator.device_info - self.forecast_day = forecast_day @property def native_value(self) -> str | int | float | None: diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index e9c4ace9b99..24024ba722f 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -24,14 +24,8 @@ }, "entity": { "sensor": { - "pressure_tendency": { - "state": { - "steady": "Steady", - "rising": "Rising", - "falling": "Falling" - } - }, - "air_quality": { + "air_quality_0d": { + "name": "Air quality today", "state": { "good": "Good", "hazardous": "Hazardous", @@ -41,80 +35,761 @@ "unhealthy": "Unhealthy" } }, - "grass_pollen": { + "air_quality_1d": { + "name": "Air quality day 1", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + }, + "air_quality_2d": { + "name": "Air quality day 2", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + }, + "air_quality_3d": { + "name": "Air quality day 3", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + }, + "air_quality_4d": { + "name": "Air quality day 4", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + }, + "apparent_temperature": { + "name": "Apparent temperature" + }, + "cloud_ceiling": { + "name": "Cloud ceiling" + }, + "cloud_cover": { + "name": "Cloud cover" + }, + "cloud_cover_day_0d": { + "name": "Cloud cover today" + }, + "cloud_cover_day_1d": { + "name": "Cloud cover day 1" + }, + "cloud_cover_day_2d": { + "name": "Cloud cover day 2" + }, + "cloud_cover_day_3d": { + "name": "Cloud cover day 3" + }, + "cloud_cover_day_4d": { + "name": "Cloud cover day 4" + }, + "cloud_cover_night_0d": { + "name": "Cloud cover tonight" + }, + "cloud_cover_night_1d": { + "name": "Cloud cover night 1" + }, + "cloud_cover_night_2d": { + "name": "Cloud cover night 2" + }, + "cloud_cover_night_3d": { + "name": "Cloud cover night 3" + }, + "cloud_cover_night_4d": { + "name": "Cloud cover night 4" + }, + "condition_day_0d": { + "name": "Condition today" + }, + "condition_day_1d": { + "name": "Condition day 1" + }, + "condition_day_2d": { + "name": "Condition day 2" + }, + "condition_day_3d": { + "name": "Condition day 3" + }, + "condition_day_4d": { + "name": "Condition day 4" + }, + "condition_night_0d": { + "name": "Condition tonight" + }, + "condition_night_1d": { + "name": "Condition night 1" + }, + "condition_night_2d": { + "name": "Condition night 2" + }, + "condition_night_3d": { + "name": "Condition night 3" + }, + "condition_night_4d": { + "name": "Condition night 4" + }, + "dew_point": { + "name": "Dew point" + }, + "grass_pollen_0d": { + "name": "Grass pollen today", "state_attributes": { "level": { "name": "Level", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" } } } }, - "mold_pollen": { + "grass_pollen_1d": { + "name": "Grass pollen day 1", "state_attributes": { "level": { - "name": "Level", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" } } } }, - "ragweed_pollen": { + "grass_pollen_2d": { + "name": "Grass pollen day 2", "state_attributes": { "level": { - "name": "Level", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" } } } }, - "tree_pollen": { + "grass_pollen_3d": { + "name": "Grass pollen day 3", "state_attributes": { "level": { - "name": "Level", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "grass_pollen_4d": { + "name": "Grass pollen day 4", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "hours_of_sun_0d": { + "name": "Hours of sun today" + }, + "hours_of_sun_1d": { + "name": "Hours of sun day 1" + }, + "hours_of_sun_2d": { + "name": "Hours of sun day 2" + }, + "hours_of_sun_3d": { + "name": "Hours of sun day 3" + }, + "hours_of_sun_4d": { + "name": "Hours of sun day 4" + }, + "mold_pollen_0d": { + "name": "Mold pollen today", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "mold_pollen_1d": { + "name": "Mold pollen day 1", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "mold_pollen_2d": { + "name": "Mold pollen day 2", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "mold_pollen_3d": { + "name": "Mold pollen day 3", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "mold_pollen_4d": { + "name": "Mold pollen day 4", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "precipitation": { + "name": "[%key:component::sensor::entity_component::precipitation::name%]" + }, + "pressure_tendency": { + "name": "Pressure tendency", + "state": { + "steady": "Steady", + "rising": "Rising", + "falling": "Falling" + } + }, + "ragweed_pollen_0d": { + "name": "Ragweed pollen today", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "ragweed_pollen_1d": { + "name": "Ragweed pollen day 1", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "ragweed_pollen_2d": { + "name": "Ragweed pollen day 2", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "ragweed_pollen_3d": { + "name": "Ragweed pollen day 3", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "ragweed_pollen_4d": { + "name": "Ragweed pollen day 4", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "realfeel_temperature": { + "name": "RealFeel temperature" + }, + "realfeel_temperature_max_0d": { + "name": "RealFeel temperature max today" + }, + "realfeel_temperature_max_1d": { + "name": "RealFeel temperature max day 1" + }, + "realfeel_temperature_max_2d": { + "name": "RealFeel temperature max day 2" + }, + "realfeel_temperature_max_3d": { + "name": "RealFeel temperature max day 3" + }, + "realfeel_temperature_max_4d": { + "name": "RealFeel temperature max day 4" + }, + "realfeel_temperature_min_0d": { + "name": "RealFeel temperature min today" + }, + "realfeel_temperature_min_1d": { + "name": "RealFeel temperature min day 1" + }, + "realfeel_temperature_min_2d": { + "name": "RealFeel temperature min day 2" + }, + "realfeel_temperature_min_3d": { + "name": "RealFeel temperature min day 3" + }, + "realfeel_temperature_min_4d": { + "name": "RealFeel temperature min day 4" + }, + "realfeel_temperature_shade": { + "name": "RealFeel temperature shade" + }, + "realfeel_temperature_shade_max_0d": { + "name": "RealFeel temperature shade max today" + }, + "realfeel_temperature_shade_max_1d": { + "name": "RealFeel temperature shade max day 1" + }, + "realfeel_temperature_shade_max_2d": { + "name": "RealFeel temperature shade max day 2" + }, + "realfeel_temperature_shade_max_3d": { + "name": "RealFeel temperature shade max day 3" + }, + "realfeel_temperature_shade_max_4d": { + "name": "RealFeel temperature shade max day 4" + }, + "realfeel_temperature_shade_min_0d": { + "name": "RealFeel temperature shade min today" + }, + "realfeel_temperature_shade_min_1d": { + "name": "RealFeel temperature shade min day 1" + }, + "realfeel_temperature_shade_min_2d": { + "name": "RealFeel temperature shade min day 2" + }, + "realfeel_temperature_shade_min_3d": { + "name": "RealFeel temperature shade min day 3" + }, + "realfeel_temperature_shade_min_4d": { + "name": "RealFeel temperature shade min day 4" + }, + "solar_irradiance_day_0d": { + "name": "Solar irradiance today" + }, + "solar_irradiance_day_1d": { + "name": "Solar irradiance day 1" + }, + "solar_irradiance_day_2d": { + "name": "Solar irradiance day 2" + }, + "solar_irradiance_day_3d": { + "name": "Solar irradiance day 3" + }, + "solar_irradiance_day_4d": { + "name": "Solar irradiance day 4" + }, + "solar_irradiance_night_0d": { + "name": "Solar irradiance tonight" + }, + "solar_irradiance_night_1d": { + "name": "Solar irradiance night 1" + }, + "solar_irradiance_night_2d": { + "name": "Solar irradiance night 2" + }, + "solar_irradiance_night_3d": { + "name": "Solar irradiance night 3" + }, + "solar_irradiance_night_4d": { + "name": "Solar irradiance night 4" + }, + "thunderstorm_probability_day_0d": { + "name": "Thunderstorm probability today" + }, + "thunderstorm_probability_day_1d": { + "name": "Thunderstorm probability day 1" + }, + "thunderstorm_probability_day_2d": { + "name": "Thunderstorm probability day 2" + }, + "thunderstorm_probability_day_3d": { + "name": "Thunderstorm probability day 3" + }, + "thunderstorm_probability_day_4d": { + "name": "Thunderstorm probability day 4" + }, + "thunderstorm_probability_night_0d": { + "name": "Thunderstorm probability tonight" + }, + "thunderstorm_probability_night_1d": { + "name": "Thunderstorm probability night 1" + }, + "thunderstorm_probability_night_2d": { + "name": "Thunderstorm probability night 2" + }, + "thunderstorm_probability_night_3d": { + "name": "Thunderstorm probability night 3" + }, + "thunderstorm_probability_night_4d": { + "name": "Thunderstorm probability night 4" + }, + "tree_pollen_0d": { + "name": "Tree pollen today", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "tree_pollen_1d": { + "name": "Tree pollen day 1", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "tree_pollen_2d": { + "name": "Tree pollen day 2", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "tree_pollen_3d": { + "name": "Tree pollen day 3", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "tree_pollen_4d": { + "name": "Tree pollen day 4", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" } } } }, "uv_index": { + "name": "UV index", "state_attributes": { "level": { - "name": "Level", + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" } } } + }, + "uv_index_0d": { + "name": "UV index today", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "uv_index_1d": { + "name": "UV index day 1", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "uv_index_2d": { + "name": "UV index day 2", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "uv_index_3d": { + "name": "UV index day 3", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "uv_index_4d": { + "name": "UV index day 4", + "state_attributes": { + "level": { + "name": "[%key:component::accuweather::entity::sensor::grass_pollen_0d::state_attributes::level::name%]", + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::hazardous%]", + "high": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality_0d::state::unhealthy%]" + } + } + } + }, + "wet_bulb_temperature": { + "name": "Wet bulb temperature" + }, + "wind_speed": { + "name": "[%key:component::weather::entity_component::_::state_attributes::wind_speed::name%]" + }, + "wind_chill_temperature": { + "name": "Wind chill temperature" + }, + "wind_gust_speed": { + "name": "[%key:component::weather::entity_component::_::state_attributes::wind_gust_speed::name%]" + }, + "wind_gust_speed_day_0d": { + "name": "Wind gust speed today" + }, + "wind_gust_speed_day_1d": { + "name": "Wind gust speed day 1" + }, + "wind_gust_speed_day_2d": { + "name": "Wind gust speed day 2" + }, + "wind_gust_speed_day_3d": { + "name": "Wind gust speed day 3" + }, + "wind_gust_speed_day_4d": { + "name": "Wind gust speed day 4" + }, + "wind_gust_speed_night_0d": { + "name": "Wind gust speed tonight" + }, + "wind_gust_speed_night_1d": { + "name": "Wind gust speed night 1" + }, + "wind_gust_speed_night_2d": { + "name": "Wind gust speed night 2" + }, + "wind_gust_speed_night_3d": { + "name": "Wind gust speed night 3" + }, + "wind_gust_speed_night_4d": { + "name": "Wind gust speed night 4" + }, + "wind_speed_day_0d": { + "name": "Wind speed today" + }, + "wind_speed_day_1d": { + "name": "Wind speed day 1" + }, + "wind_speed_day_2d": { + "name": "Wind speed day 2" + }, + "wind_speed_day_3d": { + "name": "Wind speed day 3" + }, + "wind_speed_day_4d": { + "name": "Wind speed day 4" + }, + "wind_speed_night_0d": { + "name": "Wind speed tonight" + }, + "wind_speed_night_1d": { + "name": "Wind speed night 1" + }, + "wind_speed_night_2d": { + "name": "Wind speed night 2" + }, + "wind_speed_night_3d": { + "name": "Wind speed night 3" + }, + "wind_speed_night_4d": { + "name": "Wind speed night 4" } } }, diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 30dae28c408..d446b4b58d9 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -17,7 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,9 +28,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp from . import AccuWeatherDataUpdateCoordinator @@ -40,7 +40,7 @@ from .const import ( ATTR_SPEED, ATTR_VALUE, ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, ) @@ -58,7 +58,7 @@ async def async_setup_entry( class AccuWeatherEntity( - CoordinatorEntity[AccuWeatherDataUpdateCoordinator], WeatherEntity + SingleCoordinatorWeatherEntity[AccuWeatherDataUpdateCoordinator] ): """Define an AccuWeather entity.""" @@ -68,9 +68,6 @@ class AccuWeatherEntity( def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None: """Initialize.""" super().__init__(coordinator) - # Coordinator data is used also for sensors which don't have units automatically - # converted, hence the weather entity's native units follow the configured unit - # system self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS self._attr_native_pressure_unit = UnitOfPressure.HPA self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -79,18 +76,13 @@ class AccuWeatherEntity( self._attr_unique_id = coordinator.location_key self._attr_attribution = ATTRIBUTION self._attr_device_info = coordinator.device_info + if self.coordinator.forecast: + self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY @property def condition(self) -> str | None: """Return the current condition.""" - try: - return [ - k - for k, v in CONDITION_CLASSES.items() - if self.coordinator.data["WeatherIcon"] in v - ][0] - except IndexError: - return None + return CONDITION_MAP.get(self.coordinator.data["WeatherIcon"]) @property def cloud_coverage(self) -> float: @@ -180,9 +172,12 @@ class AccuWeatherEntity( ], ATTR_FORECAST_UV_INDEX: item["UVIndex"][ATTR_VALUE], ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"], - ATTR_FORECAST_CONDITION: [ - k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v - ][0], + ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["IconDay"]), } for item in self.coordinator.data[ATTR_FORECAST] ] + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.forecast diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 2fc106f75f5..9ad01ba6f29 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -74,9 +74,9 @@ class AcmedaBase(entity.Entity): return self.roller.id @property - def device_info(self) -> entity.DeviceInfo: + def device_info(self) -> dr.DeviceInfo: """Return the device info.""" - return entity.DeviceInfo( + return dr.DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, manufacturer="Rollease Acmeda", name=self.roller.name, diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index f1bd0613f1e..b0dd287f428 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio +from asyncio import timeout from contextlib import suppress from typing import Any import aiopulse -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -43,7 +43,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): hubs: list[aiopulse.Hub] = [] with suppress(asyncio.TimeoutError): - async with async_timeout.timeout(5): + async with timeout(5): async for hub in aiopulse.Hub.discover(): if hub.id not in already_configured: hubs.append(hub) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 0db6a3615f6..7587bfc0799 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py index 3a60ad4e8b1..909acd89b80 100644 --- a/homeassistant/components/adguard/entity.py +++ b/homeassistant/components/adguard/entity.py @@ -4,8 +4,8 @@ from __future__ import annotations from adguardhome import AdGuardHome, AdGuardHomeError from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DATA_ADGUARD_VERSION, DOMAIN, LOGGER diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 5d1e9f2b656..1f80553031b 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -1,12 +1,12 @@ """Support for Automation Device Specification (ADS).""" import asyncio +from asyncio import timeout from collections import namedtuple import ctypes import logging import struct import threading -import async_timeout import pyads import voluptuous as vol @@ -301,7 +301,7 @@ class AdsEntity(Entity): self._ads_hub.add_device_notification, ads_var, plctype, update ) try: - async with async_timeout.timeout(10): + async with timeout(10): await self._event.wait() except asyncio.TimeoutError: _LOGGER.debug("Variable %s: Timeout during first update", ads_var) diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index 9e4f92e8c98..00750fb4e94 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -4,7 +4,7 @@ from typing import Any from advantage_air import ApiError from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index 7815354dd92..d0aca153d4c 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index a646ba3b521..8afde183110 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -3,7 +3,7 @@ from homeassistant.components.update import UpdateEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 032e0a3a9f6..c8b3f774a97 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,11 +1,14 @@ """The AEMET OpenData component.""" + import logging -from aemet_opendata.interface import AEMET +from aemet_opendata.exceptions import TownNotFound +from aemet_opendata.interface import AEMET, ConnectionOptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client from .const import ( CONF_STATION_UPDATES, @@ -27,11 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = entry.data[CONF_LONGITUDE] station_updates = entry.options.get(CONF_STATION_UPDATES, True) - aemet = AEMET(api_key) - weather_coordinator = WeatherUpdateCoordinator( - hass, aemet, latitude, longitude, station_updates - ) + options = ConnectionOptions(api_key, station_updates) + aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) + try: + await aemet.select_coordinates(latitude, longitude) + except TownNotFound as err: + _LOGGER.error(err) + return False + weather_coordinator = WeatherUpdateCoordinator(hass, aemet) await weather_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 9db0c6f7db1..4df25613803 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,13 +1,14 @@ """Config flow for AEMET OpenData.""" from __future__ import annotations -from aemet_opendata import AEMET +from aemet_opendata.exceptions import AuthError +from aemet_opendata.interface import AEMET, ConnectionOptions import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, @@ -39,8 +40,11 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - api_online = await _is_aemet_api_online(self.hass, user_input[CONF_API_KEY]) - if not api_online: + options = ConnectionOptions(user_input[CONF_API_KEY], False) + aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) + try: + await aemet.select_coordinates(latitude, longitude) + except AuthError: errors["base"] = "invalid_api_key" if not errors: @@ -70,10 +74,3 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> SchemaOptionsFlowHandler: """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - - -async def _is_aemet_api_online(hass, api_key): - aemet = AEMET(api_key) - return await hass.async_add_executor_job( - aemet.get_conventional_observation_stations, False - ) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index bd10d09bea0..c6c4a9c1628 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -33,6 +33,7 @@ ATTR_API_FORECAST_TEMP = "temperature" ATTR_API_FORECAST_TEMP_LOW = "templow" ATTR_API_FORECAST_TIME = "datetime" ATTR_API_FORECAST_WIND_BEARING = "wind_bearing" +ATTR_API_FORECAST_WIND_MAX_SPEED = "wind_max_speed" ATTR_API_FORECAST_WIND_SPEED = "wind_speed" ATTR_API_HUMIDITY = "humidity" ATTR_API_PRESSURE = "pressure" diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index f9f1129f3b0..1c65572a64e 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.2.2"] + "requirements": ["AEMET-OpenData==0.4.4"] } diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index aba5a2781d0..e3a1922c2f1 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,14 +1,20 @@ """Support for the AEMET OpenData service.""" +from typing import cast + from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - WeatherEntity, + DOMAIN as WEATHER_DOMAIN, + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,9 +23,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_API_CONDITION, @@ -30,6 +36,7 @@ from .const import ( 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, @@ -64,6 +71,7 @@ FORECAST_MAP = { ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_MAX_SPEED: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, }, } @@ -79,15 +87,33 @@ async def async_setup_entry( weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] entities = [] - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - unique_id = f"{config_entry.unique_id} {mode}" - entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) + entity_registry = er.async_get(hass) + + # Add daily + hourly entity for legacy config entries, only add daily for new + # config entries. This can be removed in HA Core 2024.3 + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}", + ): + for mode in FORECAST_MODES: + name = f"{domain_data[ENTRY_NAME]} {mode}" + unique_id = f"{config_entry.unique_id} {mode}" + entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) + else: + entities.append( + AemetWeather( + domain_data[ENTRY_NAME], + config_entry.unique_id, + weather_coordinator, + FORECAST_MODE_DAILY, + ) + ) async_add_entities(entities, False) -class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): +class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION @@ -95,6 +121,9 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__( self, @@ -117,15 +146,32 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): """Return the current condition.""" return self.coordinator.data[ATTR_API_CONDITION] - @property - def forecast(self): + def _forecast(self, forecast_mode: str) -> list[Forecast]: """Return the forecast array.""" - forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]] - forecast_map = FORECAST_MAP[self._forecast_mode] - return [ - {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} - for forecast in forecasts - ] + forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]] + forecast_map = FORECAST_MAP[forecast_mode] + return cast( + list[Forecast], + [ + {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} + for forecast in forecasts + ], + ) + + @property + def forecast(self) -> list[Forecast]: + """Return the forecast array.""" + return self._forecast(self._forecast_mode) + + @callback + def _async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast(FORECAST_MODE_DAILY) + + @callback + def _async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast in native units.""" + return self._forecast(FORECAST_MODE_HOURLY) @property def humidity(self): diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 5242540748f..c6e27374f8f 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -1,22 +1,21 @@ """Weather data coordinator for the AEMET OpenData service.""" from __future__ import annotations -from dataclasses import dataclass, field +from asyncio import timeout from datetime import timedelta import logging +from typing import Any, Final 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_ID, - AEMET_ATTR_IDEMA, AEMET_ATTR_MAX, AEMET_ATTR_MIN, - AEMET_ATTR_NAME, AEMET_ATTR_PRECIPITATION, AEMET_ATTR_PRECIPITATION_PROBABILITY, AEMET_ATTR_SKY_STATE, @@ -25,24 +24,24 @@ from aemet_opendata.const import ( AEMET_ATTR_SPEED, AEMET_ATTR_STATION_DATE, AEMET_ATTR_STATION_HUMIDITY, - AEMET_ATTR_STATION_LOCATION, AEMET_ATTR_STATION_PRESSURE, AEMET_ATTR_STATION_PRESSURE_SEA, AEMET_ATTR_STATION_TEMPERATURE, AEMET_ATTR_STORM_PROBABILITY, AEMET_ATTR_TEMPERATURE, - AEMET_ATTR_TEMPERATURE_FEELING, AEMET_ATTR_WIND, AEMET_ATTR_WIND_GUST, ATTR_DATA, ) +from aemet_opendata.exceptions import AemetError from aemet_opendata.helpers import ( get_forecast_day_value, get_forecast_hour_value, get_forecast_interval_value, ) -import async_timeout +from aemet_opendata.interface import AEMET +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -57,6 +56,7 @@ from .const import ( 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, @@ -83,6 +83,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +API_TIMEOUT: Final[int] = 120 STATION_MAX_DELTA = timedelta(hours=2) WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) @@ -112,128 +113,33 @@ def format_int(value) -> int | None: return None -class TownNotFound(UpdateFailed): - """Raised when town is not found.""" - - class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" - def __init__(self, hass, aemet, latitude, longitude, station_updates): + def __init__( + self, + hass: HomeAssistant, + aemet: AEMET, + ) -> None: """Initialize coordinator.""" + self.aemet = aemet + super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL + hass, + _LOGGER, + name=DOMAIN, + update_interval=WEATHER_UPDATE_INTERVAL, ) - self._aemet = aemet - self._station = None - self._town = None - self._latitude = latitude - self._longitude = longitude - self._station_updates = station_updates - self._data = { - "daily": None, - "hourly": None, - "station": None, - } - - async def _async_update_data(self): - data = {} - async with async_timeout.timeout(120): - weather_response = await self._get_aemet_weather() - data = self._convert_weather_response(weather_response) - return data - - async def _get_aemet_weather(self): - """Poll weather data from AEMET OpenData.""" - weather = await self.hass.async_add_executor_job(self._get_weather_and_forecast) - return weather - - def _get_weather_station(self): - if not self._station: - self._station = ( - self._aemet.get_conventional_observation_station_by_coordinates( - self._latitude, self._longitude - ) - ) - if self._station: - _LOGGER.debug( - "station found for coordinates [%s, %s]: %s", - self._latitude, - self._longitude, - self._station, - ) - if not self._station: - _LOGGER.debug( - "station not found for coordinates [%s, %s]", - self._latitude, - self._longitude, - ) - return self._station - - def _get_weather_town(self): - if not self._town: - self._town = self._aemet.get_town_by_coordinates( - self._latitude, self._longitude - ) - if self._town: - _LOGGER.debug( - "Town found for coordinates [%s, %s]: %s", - self._latitude, - self._longitude, - self._town, - ) - if not self._town: - _LOGGER.error( - "Town not found for coordinates [%s, %s]", - self._latitude, - self._longitude, - ) - raise TownNotFound - return self._town - - def _get_weather_and_forecast(self): - """Get weather and forecast data from AEMET OpenData.""" - - self._get_weather_town() - - daily = self._aemet.get_specific_forecast_town_daily(self._town[AEMET_ATTR_ID]) - if not daily: - _LOGGER.error( - 'Error fetching daily data for town "%s"', self._town[AEMET_ATTR_ID] - ) - - hourly = self._aemet.get_specific_forecast_town_hourly( - self._town[AEMET_ATTR_ID] - ) - if not hourly: - _LOGGER.error( - 'Error fetching hourly data for town "%s"', self._town[AEMET_ATTR_ID] - ) - - station = None - if self._station_updates and self._get_weather_station(): - station = self._aemet.get_conventional_observation_station_data( - self._station[AEMET_ATTR_IDEMA] - ) - if not station: - _LOGGER.error( - 'Error fetching data for station "%s"', - self._station[AEMET_ATTR_IDEMA], - ) - - if daily: - self._data["daily"] = daily - if hourly: - self._data["hourly"] = hourly - if station: - self._data["station"] = station - - return AemetWeather( - self._data["daily"], - self._data["hourly"], - self._data["station"], - ) + 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.""" @@ -428,6 +334,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ), 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), } @@ -518,14 +425,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_station_id(self): """Get station ID from weather data.""" - if self._station: - return self._station[AEMET_ATTR_IDEMA] + 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._station: - return self._station[AEMET_ATTR_STATION_LOCATION] + if self.aemet.station: + return self.aemet.station.get_name() return None @staticmethod @@ -561,19 +468,19 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): @staticmethod def _get_temperature_feeling(day_data, hour): """Get temperature from weather data.""" - val = get_forecast_hour_value(day_data[AEMET_ATTR_TEMPERATURE_FEELING], hour) + 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._town: - return self._town[AEMET_ATTR_ID] + 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._town: - return self._town[AEMET_ATTR_NAME] + if self.aemet.town: + return self.aemet.town.get_name() return None @staticmethod @@ -623,12 +530,3 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): if val: return format_int(val) return None - - -@dataclass -class AemetWeather: - """Class to harmonize weather data model.""" - - daily: dict = field(default_factory=dict) - hourly: dict = field(default_factory=dict) - station: dict = field(default_factory=dict) diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index dc8038862c6..1ac26e2eb79 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -13,7 +13,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONNECTION, DOMAIN as AGENT_DOMAIN diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index d49a1ac387e..cf171987fcb 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -8,7 +8,7 @@ from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index f52bdca4b86..982687c7723 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,6 +1,7 @@ """The Airly integration.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from math import ceil @@ -9,7 +10,6 @@ from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from airly import Airly from airly.exceptions import AirlyError -import async_timeout from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -167,7 +167,7 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator): measurements = self.airly.create_measurements_session_point( self.latitude, self.longitude ) - async with async_timeout.timeout(20): + async with timeout(20): try: await measurements.update() except (AirlyError, ClientConnectorError) as error: diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index 5d41116eaa1..27c7b0f91e3 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -1,13 +1,13 @@ """Adds config flow for Airly.""" from __future__ import annotations +from asyncio import timeout from http import HTTPStatus from typing import Any from aiohttp import ClientSession from airly import Airly from airly.exceptions import AirlyError -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -105,7 +105,7 @@ async def test_location( measurements = airly.create_measurements_session_point( latitude=latitude, longitude=longitude ) - async with async_timeout.timeout(10): + async with timeout(10): await measurements.update() current = measurements.current diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index cfbe7b98883..864c36f171a 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -20,8 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 7c26cded4de..c4d52c6ac8e 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -47,7 +47,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key = entry.data[CONF_API_KEY] latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] - distance = entry.data[CONF_RADIUS] + + # Station Radius is a user-configurable option + distance = entry.options[CONF_RADIUS] # Reports are published hourly but update twice per hour update_interval = datetime.timedelta(minutes=30) @@ -65,11 +67,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator + # Listen for option changes + entry.async_on_unload(entry.add_update_listener(update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + new_options = {CONF_RADIUS: entry.data[CONF_RADIUS]} + new_data = entry.data.copy() + del new_data[CONF_RADIUS] + + entry.version = 2 + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options + ) + + _LOGGER.info("Migration to version %s successful", entry.version) + + 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) @@ -80,6 +104,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + class AirNowDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold Airly data.""" diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 67bce66e167..d72d145f7de 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -1,11 +1,12 @@ """Config flow for AirNow integration.""" import logging +from typing import Any from pyairnow import WebServiceAPI from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, core, data_entry_flow, exceptions from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -48,7 +49,7 @@ async def validate_input(hass: core.HomeAssistant, data): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for AirNow.""" - VERSION = 1 + VERSION = 2 async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -75,12 +76,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: # Create Entry + radius = user_input.pop(CONF_RADIUS) return self.async_create_entry( title=( f"AirNow Sensor at {user_input[CONF_LATITUDE]}," f" {user_input[CONF_LONGITUDE]}" ), data=user_input, + options={CONF_RADIUS: radius}, ) return self.async_show_form( @@ -94,12 +97,49 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional(CONF_RADIUS, default=150): int, + vol.Optional(CONF_RADIUS, default=150): vol.All( + int, vol.Range(min=5) + ), } ), errors=errors, ) + @staticmethod + @core.callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Return the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): + """Handle an options flow for AirNow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + options_schema = vol.Schema( + { + vol.Optional(CONF_RADIUS): vol.All( + int, + vol.Range(min=5), + ), + } + ) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + options_schema, self.config_entry.options + ), + ) + class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index f3d29cc65df..09393741d63 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -17,8 +17,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 @@ -30,6 +29,9 @@ from .const import ( ATTR_API_AQI_LEVEL, ATTR_API_O3, ATTR_API_PM25, + ATTR_API_STATION, + ATTR_API_STATION_LATITUDE, + ATTR_API_STATION_LONGITUDE, DEFAULT_NAME, DOMAIN, ) @@ -40,6 +42,7 @@ PARALLEL_UPDATES = 1 ATTR_DESCR = "description" ATTR_LEVEL = "level" +ATTR_STATION = "reporting_station" @dataclass @@ -85,6 +88,16 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( value_fn=lambda data: data.get(ATTR_API_O3), extra_state_attributes_fn=None, ), + AirNowEntityDescription( + key=ATTR_API_STATION, + translation_key="station", + icon="mdi:blur", + value_fn=lambda data: data.get(ATTR_API_STATION), + extra_state_attributes_fn=lambda data: { + "lat": data[ATTR_API_STATION_LATITUDE], + "long": data[ATTR_API_STATION_LONGITUDE], + }, + ), ) diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 072f0988c19..93ca14710b7 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -21,10 +21,26 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "options": { + "step": { + "init": { + "data": { + "radius": "Station Radius (miles)" + } + } + } + }, "entity": { "sensor": { "o3": { "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "station": { + "name": "PM2.5 reporting station", + "state_attributes": { + "lat": { "name": "[%key:common::config_flow::data::latitude%]" }, + "long": { "name": "[%key:common::config_flow::data::longitude%]" } + } } } } diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 78e9580c631..2d0d9d199df 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 9c9859306ca..cd4e9d52f6b 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +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 ( diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 6d5df7ddd56..b562e837ff4 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -34,8 +34,12 @@ class Discovery: def get_name(device: AirthingsDevice) -> str: - """Generate name with identifier for device.""" - return f"{device.name} ({device.identifier})" + """Generate name with model and identifier for device.""" + + name = device.friendly_name() + if identifier := device.identifier: + name += f" ({identifier})" + return name class AirthingsDeviceUpdateError(Exception): @@ -156,7 +160,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="no_devices_found") titles = { - address: get_name(discovery.device) + address: discovery.device.name for (address, discovery) in self._discovered_devices.items() } return self.async_show_form( diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 8c78bbfb58d..ef9ad3a802e 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.5.3"] + "requirements": ["airthings-ble==0.5.6-2"] } diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 98190df6b8d..4783f3e3b35 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -22,8 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( @@ -162,11 +161,11 @@ class AirthingsSensor( super().__init__(coordinator) self.entity_description = entity_description - name = f"{airthings_device.name} {airthings_device.identifier}" + name = airthings_device.name + if identifier := airthings_device.identifier: + name += f" ({identifier})" self._attr_unique_id = f"{name}_{entity_description.key}" - - self._id = airthings_device.address self._attr_device_info = DeviceInfo( connections={ ( @@ -175,9 +174,10 @@ class AirthingsSensor( ) }, name=name, - manufacturer="Airthings", + manufacturer=airthings_device.manufacturer, hw_version=airthings_device.hw_version, sw_version=airthings_device.sw_version, + model=airthings_device.model, ) @property diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 52e234505c1..bd1c481ce65 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -18,7 +18,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -98,28 +98,20 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): self._ac_number = ac_number self._airtouch = coordinator.airtouch self._info = info - self._unit = self._airtouch.GetAcs()[self._ac_number] + self._unit = self._airtouch.GetAcs()[ac_number] + self._attr_unique_id = f"ac_{ac_number}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"ac_{ac_number}")}, + name=f"AC {ac_number}", + manufacturer="Airtouch", + model="Airtouch 4", + ) @callback def _handle_coordinator_update(self): self._unit = self._airtouch.GetAcs()[self._ac_number] return super()._handle_coordinator_update() - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=f"AC {self._ac_number}", - manufacturer="Airtouch", - model="Airtouch 4", - ) - - @property - def unique_id(self): - """Return unique ID for this device.""" - return f"ac_{self._ac_number}" - @property def current_temperature(self): """Return the current temperature.""" @@ -208,29 +200,21 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): """Initialize the climate device.""" super().__init__(coordinator) self._group_number = group_number + self._attr_unique_id = group_number self._airtouch = coordinator.airtouch self._info = info - self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) - - @callback - def _handle_coordinator_update(self): - self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) - return super()._handle_coordinator_update() - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, + self._unit = self._airtouch.GetGroupByGroupNumber(group_number) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, group_number)}, manufacturer="Airtouch", model="Airtouch 4", name=self._unit.GroupName, ) - @property - def unique_id(self): - """Return unique ID for this device.""" - return self._group_number + @callback + def _handle_coordinator_update(self): + self._unit = self._airtouch.GetGroupByGroupNumber(self._group_number) + return super()._handle_coordinator_update() @property def min_temp(self): diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json index 1e03a88da6c..e845c278a54 100644 --- a/homeassistant/components/airtouch4/manifest.json +++ b/homeassistant/components/airtouch4/manifest.json @@ -1,7 +1,7 @@ { "domain": "airtouch4", "name": "AirTouch 4", - "codeowners": ["@LonePurpleWolf"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airtouch4", "iot_class": "local_polling", diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 27e79f2d40b..893726fc022 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -109,19 +109,21 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "airvisual_checked_api_keys_lock", asyncio.Lock() ) - if integration_type == INTEGRATION_TYPE_GEOGRAPHY_COORDS: - coro = cloud_api.air_quality.nearest_city() - error_schema = self.geography_coords_schema - error_step = "geography_by_coords" - else: - coro = cloud_api.air_quality.city( - user_input[CONF_CITY], user_input[CONF_STATE], user_input[CONF_COUNTRY] - ) - error_schema = GEOGRAPHY_NAME_SCHEMA - error_step = "geography_by_name" - async with valid_keys_lock: if user_input[CONF_API_KEY] not in valid_keys: + if integration_type == INTEGRATION_TYPE_GEOGRAPHY_COORDS: + coro = cloud_api.air_quality.nearest_city() + error_schema = self.geography_coords_schema + error_step = "geography_by_coords" + else: + coro = cloud_api.air_quality.city( + user_input[CONF_CITY], + user_input[CONF_STATE], + user_input[CONF_COUNTRY], + ) + error_schema = GEOGRAPHY_NAME_SCHEMA + error_step = "geography_by_name" + try: await coro except (InvalidKeyError, KeyExpiredError, UnauthorizedError): diff --git a/homeassistant/components/airvisual_pro/__init__.py b/homeassistant/components/airvisual_pro/__init__.py index 5bbbb0e895d..3e53fc15b4f 100644 --- a/homeassistant/components/airvisual_pro/__init__.py +++ b/homeassistant/components/airvisual_pro/__init__.py @@ -23,7 +23,8 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/airzone/coordinator.py b/homeassistant/components/airzone/coordinator.py index ba0296557a1..6053c587550 100644 --- a/homeassistant/components/airzone/coordinator.py +++ b/homeassistant/components/airzone/coordinator.py @@ -1,13 +1,13 @@ """The Airzone integration.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from typing import Any from aioairzone.exceptions import AirzoneError from aioairzone.localapi import AirzoneLocalApi -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - async with async_timeout.timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC): + async with timeout(AIOAIRZONE_DEVICE_TIMEOUT_SEC): try: await self.airzone.update() except AirzoneError as error: diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 9ee923ba1af..267cd210ff0 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -10,6 +10,7 @@ from aioairzone.const import ( AZD_AVAILABLE, AZD_FIRMWARE, AZD_FULL_NAME, + AZD_HOT_WATER, AZD_ID, AZD_MAC, AZD_MODEL, @@ -26,7 +27,7 @@ from aioairzone.exceptions import AirzoneError from homeassistant.config_entries import ConfigEntry from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -81,6 +82,31 @@ class AirzoneSystemEntity(AirzoneEntity): return value +class AirzoneHotWaterEntity(AirzoneEntity): + """Define an Airzone Hot Water entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_dhw")}, + manufacturer=MANUFACTURER, + model="DHW", + name=self.get_airzone_value(AZD_NAME), + via_device=(DOMAIN, f"{entry.entry_id}_ws"), + ) + self._attr_unique_id = entry.unique_id or entry.entry_id + + def get_airzone_value(self, key: str) -> Any: + """Return DHW value by key.""" + return self.coordinator.data[AZD_HOT_WATER].get(key) + + class AirzoneWebServerEntity(AirzoneEntity): """Define an Airzone WebServer entity.""" diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 39adf08236e..c0b24b2cc3e 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.5"] + "requirements": ["aioairzone==0.6.8"] } diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index d90fdf93607..1dd67294aff 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, Final from aioairzone.const import ( + AZD_HOT_WATER, AZD_HUMIDITY, AZD_NAME, AZD_TEMP, @@ -31,7 +32,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneEntity, AirzoneWebServerEntity, AirzoneZoneEntity +from .entity import ( + AirzoneEntity, + AirzoneHotWaterEntity, + AirzoneWebServerEntity, + AirzoneZoneEntity, +) + +HOT_WATER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), +) WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( @@ -71,6 +86,18 @@ async def async_setup_entry( sensors: list[AirzoneSensor] = [] + if AZD_HOT_WATER in coordinator.data: + dhw_data = coordinator.data[AZD_HOT_WATER] + for description in HOT_WATER_SENSOR_TYPES: + if description.key in dhw_data: + sensors.append( + AirzoneHotWaterSensor( + coordinator, + description, + entry, + ) + ) + if AZD_WEBSERVER in coordinator.data: ws_data = coordinator.data[AZD_WEBSERVER] for description in WEBSERVER_SENSOR_TYPES: @@ -114,6 +141,30 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): self._attr_native_value = self.get_airzone_value(self.entity_description.key) +class AirzoneHotWaterSensor(AirzoneHotWaterEntity, AirzoneSensor): + """Define an Airzone Hot Water sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{self._attr_unique_id}_dhw_{description.key}" + self.entity_description = description + + self._attr_native_unit_of_measurement = TEMP_UNIT_LIB_TO_HASS.get( + self.get_airzone_value(AZD_TEMP_UNIT) + ) + + self._async_update_attrs() + + class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Define an Airzone WebServer sensor.""" diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 765eec2d288..a364ad0d753 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -9,6 +9,7 @@ from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_ERRORS, AZD_PROBLEMS, + AZD_SYSTEMS, AZD_WARNINGS, AZD_ZONES, ) @@ -25,7 +26,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneSystemEntity, + AirzoneZoneEntity, +) @dataclass @@ -51,6 +57,20 @@ AIDOO_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ... ), ) + +SYSTEM_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = ( + AirzoneBinarySensorEntityDescription( + attributes={ + "errors": AZD_ERRORS, + "warnings": AZD_WARNINGS, + }, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + key=AZD_PROBLEMS, + ), +) + + ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = ( AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, @@ -87,6 +107,18 @@ async def async_setup_entry( ) ) + for system_id, system_data in coordinator.data.get(AZD_SYSTEMS, {}).items(): + for description in SYSTEM_BINARY_SENSOR_TYPES: + if description.key in system_data: + binary_sensors.append( + AirzoneSystemBinarySensor( + coordinator, + description, + system_id, + system_data, + ) + ) + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): for description in ZONE_BINARY_SENSOR_TYPES: if description.key in zone_data: @@ -145,6 +177,27 @@ class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): self._async_update_attrs() +class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): + """Define an Airzone Cloud System binary sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneBinarySensorEntityDescription, + system_id: str, + system_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, system_id, system_data) + + self._attr_unique_id = f"{system_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Define an Airzone Cloud Zone binary sensor.""" diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py index edd99355092..37b31c68ee7 100644 --- a/homeassistant/components/airzone_cloud/coordinator.py +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -1,13 +1,13 @@ """The Airzone Cloud integration coordinator.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging from typing import Any from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.exceptions import AirzoneCloudError -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -35,7 +35,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): + async with timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): try: await self.airzone.update() except AirzoneCloudError as error: diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 9b3dfdae06c..090e81e4170 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -10,13 +10,14 @@ from aioairzone_cloud.const import ( AZD_FIRMWARE, AZD_NAME, AZD_SYSTEM_ID, + AZD_SYSTEMS, AZD_WEBSERVER, AZD_WEBSERVERS, AZD_ZONES, ) from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -65,6 +66,35 @@ class AirzoneAidooEntity(AirzoneEntity): return value +class AirzoneSystemEntity(AirzoneEntity): + """Define an Airzone Cloud System entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + system_id: str, + system_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.system_id = system_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, system_id)}, + manufacturer=MANUFACTURER, + name=system_data[AZD_NAME], + via_device=(DOMAIN, system_data[AZD_WEBSERVER]), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return system value by key.""" + value = None + if system := self.coordinator.data[AZD_SYSTEMS].get(self.system_id): + value = system.get(key) + return value + + class AirzoneWebServerEntity(AirzoneEntity): """Define an Airzone Cloud WebServer entity.""" diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 25d601cf299..f466f5f4248 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index 395bbbb04a8..e3a1f2d443c 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index 7206b24632b..807d12383bc 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -16,8 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import async_track_point_in_time -from homeassistant.util import dt as dt_util +from homeassistant.helpers.event import async_call_later from .const import ( CONF_DEVICE_BAUD, @@ -66,9 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(controller.open, baud) except NoDeviceError: _LOGGER.debug("Failed to connect. Retrying in 5 seconds") - async_track_point_in_time( - hass, open_connection, dt_util.utcnow() + timedelta(seconds=5) - ) + async_call_later(hass, timedelta(seconds=5), open_connection) return _LOGGER.debug("Established a connection with the alarmdecoder") hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = True diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 0008ba26f8a..219553b3563 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.typing import ConfigType -from . import flash_briefings, intent, smart_home_http +from . import flash_briefings, intent, smart_home from .const import ( CONF_AUDIO, CONF_DISPLAY_CATEGORIES, @@ -100,6 +100,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if CONF_SMART_HOME in config: smart_home_config: dict[str, Any] | None = config[CONF_SMART_HOME] smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) - await smart_home_http.async_setup(hass, smart_home_config) + await smart_home.async_setup(hass, smart_home_config) return True diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 86c038e2da8..58095340146 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -1,15 +1,16 @@ """Support for Alexa skill auth.""" import asyncio -from datetime import timedelta +from asyncio import timeout +from datetime import datetime, timedelta from http import HTTPStatus import json import logging +from typing import Any import aiohttp -import async_timeout from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util @@ -30,24 +31,24 @@ STORAGE_REFRESH_TOKEN = "refresh_token" class Auth: """Handle authentication to send events to Alexa.""" - def __init__(self, hass, client_id, client_secret): + def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None: """Initialize the Auth class.""" self.hass = hass self.client_id = client_id self.client_secret = client_secret - self._prefs = None - self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._prefs: dict[str, Any] | None = None + self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY) self._get_token_lock = asyncio.Lock() - async def async_do_auth(self, accept_grant_code): + async def async_do_auth(self, accept_grant_code: str) -> str | None: """Do authentication with an AcceptGrant code.""" # access token not retrieved yet for the first time, so this should # be an access token request - lwa_params = { + lwa_params: dict[str, str] = { "grant_type": "authorization_code", "code": accept_grant_code, CONF_CLIENT_ID: self.client_id, @@ -61,25 +62,28 @@ class Auth: return await self._async_request_new_token(lwa_params) @callback - def async_invalidate_access_token(self): + def async_invalidate_access_token(self) -> None: """Invalidate access token.""" + assert self._prefs is not None self._prefs[STORAGE_ACCESS_TOKEN] = None - async def async_get_access_token(self): + async def async_get_access_token(self) -> str | None: """Perform access token or token refresh request.""" async with self._get_token_lock: if self._prefs is None: await self.async_load_preferences() + assert self._prefs is not None if self.is_token_valid(): _LOGGER.debug("Token still valid, using it") - return self._prefs[STORAGE_ACCESS_TOKEN] + token: str = self._prefs[STORAGE_ACCESS_TOKEN] + return token if self._prefs[STORAGE_REFRESH_TOKEN] is None: _LOGGER.debug("Token invalid and no refresh token available") return None - lwa_params = { + lwa_params: dict[str, str] = { "grant_type": "refresh_token", "refresh_token": self._prefs[STORAGE_REFRESH_TOKEN], CONF_CLIENT_ID: self.client_id, @@ -90,22 +94,26 @@ class Auth: return await self._async_request_new_token(lwa_params) @callback - def is_token_valid(self): + def is_token_valid(self) -> bool: """Check if a token is already loaded and if it is still valid.""" + assert self._prefs is not None if not self._prefs[STORAGE_ACCESS_TOKEN]: return False - expire_time = dt_util.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) + expire_time: datetime | None = dt_util.parse_datetime( + self._prefs[STORAGE_EXPIRE_TIME] + ) + assert expire_time is not None preemptive_expire_time = expire_time - timedelta( seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS ) return dt_util.utcnow() < preemptive_expire_time - async def _async_request_new_token(self, lwa_params): + async def _async_request_new_token(self, lwa_params: dict[str, str]) -> str | None: try: session = aiohttp_client.async_get_clientsession(self.hass) - async with async_timeout.timeout(10): + async with timeout(10): response = await session.post( LWA_TOKEN_URI, headers=LWA_HEADERS, @@ -127,9 +135,9 @@ class Auth: response_json = await response.json() _LOGGER.debug("LWA response body : %s", response_json) - access_token = response_json["access_token"] - refresh_token = response_json["refresh_token"] - expires_in = response_json["expires_in"] + access_token: str = response_json["access_token"] + refresh_token: str = response_json["refresh_token"] + expires_in: int = response_json["expires_in"] expire_time = dt_util.utcnow() + timedelta(seconds=expires_in) await self._async_update_preferences( @@ -138,7 +146,7 @@ class Auth: return access_token - async def async_load_preferences(self): + async def async_load_preferences(self) -> None: """Load preferences with stored tokens.""" self._prefs = await self._store.async_load() @@ -149,10 +157,13 @@ class Auth: STORAGE_EXPIRE_TIME: None, } - async def _async_update_preferences(self, access_token, refresh_token, expire_time): + async def _async_update_preferences( + self, access_token: str, refresh_token: str, expire_time: str + ) -> None: """Update user preferences.""" if self._prefs is None: await self.async_load_preferences() + assert self._prefs is not None if access_token is not None: self._prefs[STORAGE_ACCESS_TOKEN] = access_token diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 4954853b95e..a7065a38686 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1,7 +1,9 @@ """Alexa capabilities.""" from __future__ import annotations +from collections.abc import Generator import logging +from typing import Any from homeassistant.components import ( button, @@ -22,6 +24,7 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, CodeFormat, ) +from homeassistant.components.climate import HVACMode from homeassistant.const import ( ATTR_CODE_FORMAT, ATTR_SUPPORTED_FEATURES, @@ -48,7 +51,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import State +from homeassistant.core import HomeAssistant, State import homeassistant.util.color as color_util import homeassistant.util.dt as dt_util @@ -110,7 +113,9 @@ class AlexaCapability: https://developer.amazon.com/docs/device-apis/message-guide.html """ - supported_locales = {"en-US"} + _resource: AlexaCapabilityResource | None + _semantics: AlexaSemantics | None + supported_locales: set[str] = {"en-US"} def __init__( self, @@ -143,7 +148,7 @@ class AlexaCapability: """Return True if non controllable.""" return self._non_controllable_properties - def get_property(self, name): + def get_property(self, name: str) -> dict[str, Any]: """Read and return a property. Return value should be a dict, or raise UnsupportedProperty. @@ -153,63 +158,60 @@ class AlexaCapability: """ raise UnsupportedProperty(name) - def supports_deactivation(self): + def supports_deactivation(self) -> bool | None: """Applicable only to scenes.""" - return None - def capability_proactively_reported(self): + def capability_proactively_reported(self) -> bool | None: """Return True if the capability is proactively reported. Set properties_proactively_reported() for proactively reported properties. Applicable to DoorbellEventSource. """ - return None - def capability_resources(self): + def capability_resources(self) -> dict[str, list[dict[str, Any]]]: """Return the capability object. Applicable to ToggleController, RangeController, and ModeController interfaces. """ - return [] + return {} - def configuration(self): + def configuration(self) -> dict[str, Any] | None: """Return the configuration object. Applicable to the ThermostatController, SecurityControlPanel, ModeController, RangeController, and EventDetectionSensor. """ - return [] - def configurations(self): + def configurations(self) -> dict[str, Any] | None: """Return the configurations object. The plural configurations object is different that the singular configuration object. Applicable to EqualizerController interface. """ - return [] - def inputs(self): + def inputs(self) -> list[dict[str, str]] | None: """Applicable only to media players.""" - return [] - def semantics(self): + def semantics(self) -> dict[str, Any] | None: """Return the semantics object. Applicable to ToggleController, RangeController, and ModeController interfaces. """ - return [] - def supported_operations(self): + def supported_operations(self) -> list[str]: """Return the supportedOperations object.""" return [] - def camera_stream_configurations(self): + def camera_stream_configurations(self) -> list[dict[str, Any]] | None: """Applicable only to CameraStreamController.""" - return None - def serialize_discovery(self): + def serialize_discovery(self) -> dict[str, Any]: """Serialize according to the Discovery API.""" - result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"} + result: dict[str, Any] = { + "type": "AlexaInterface", + "interface": self.name(), + "version": "3", + } if (instance := self.instance) is not None: result["instance"] = instance @@ -255,7 +257,7 @@ class AlexaCapability: return result - def serialize_properties(self): + def serialize_properties(self) -> Generator[dict[str, Any], None, None]: """Return properties serialized for an API response.""" for prop in self.properties_supported(): prop_name = prop["name"] @@ -316,7 +318,7 @@ class Alexa(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa" @@ -346,28 +348,28 @@ class AlexaEndpointHealth(AlexaCapability): "pt-BR", } - def __init__(self, hass, entity): + def __init__(self, hass: HomeAssistant, entity: State) -> None: """Initialize the entity.""" super().__init__(entity) self.hass = hass - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.EndpointHealth" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "connectivity"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "connectivity": raise UnsupportedProperty(name) @@ -402,23 +404,23 @@ class AlexaPowerController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.PowerController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "powerState"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "powerState": raise UnsupportedProperty(name) @@ -465,23 +467,23 @@ class AlexaLockController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.LockController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "lockState"}] - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "lockState": raise UnsupportedProperty(name) @@ -519,12 +521,16 @@ class AlexaSceneController(AlexaCapability): "pt-BR", } - def __init__(self, entity, supports_deactivation): + def __init__(self, entity: State, supports_deactivation: bool) -> None: """Initialize the entity.""" + self._supports_deactivation = supports_deactivation super().__init__(entity) - self.supports_deactivation = lambda: supports_deactivation - def name(self): + def supports_deactivation(self) -> bool | None: + """Return True if the Scene controller supports deactivation.""" + return self._supports_deactivation + + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.SceneController" @@ -554,23 +560,23 @@ class AlexaBrightnessController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.BrightnessController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "brightness"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "brightness": raise UnsupportedProperty(name) @@ -603,23 +609,23 @@ class AlexaColorController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.ColorController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "color"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "color": raise UnsupportedProperty(name) @@ -657,23 +663,23 @@ class AlexaColorTemperatureController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.ColorTemperatureController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "colorTemperatureInKelvin"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "colorTemperatureInKelvin": raise UnsupportedProperty(name) @@ -704,11 +710,11 @@ class AlexaSpeaker(AlexaCapability): "ja-JP", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.Speaker" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" properties = [{"name": "volume"}] @@ -718,15 +724,15 @@ class AlexaSpeaker(AlexaCapability): return properties - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name == "volume": current_level = self.entity.attributes.get( @@ -761,7 +767,7 @@ class AlexaStepSpeaker(AlexaCapability): "it-IT", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.StepSpeaker" @@ -791,11 +797,11 @@ class AlexaPlaybackController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.PlaybackController" - def supported_operations(self): + def supported_operations(self) -> list[str]: """Return the supportedOperations object. Supported Operations: FastForward, Next, Pause, Play, Previous, Rewind, @@ -843,24 +849,22 @@ class AlexaInputController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.InputController" - def inputs(self): + def inputs(self) -> list[dict[str, str]] | None: """Return the list of valid supported inputs.""" - source_list = self.entity.attributes.get( + source_list: list[str] = self.entity.attributes.get( media_player.ATTR_INPUT_SOURCE_LIST, [] ) return AlexaInputController.get_valid_inputs(source_list) @staticmethod - def get_valid_inputs(source_list): + def get_valid_inputs(source_list: list[str]) -> list[dict[str, str]]: """Return list of supported inputs.""" - input_list = [] + input_list: list[dict[str, str]] = [] for source in source_list: - if not isinstance(source, str): - continue formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") ) @@ -897,50 +901,52 @@ class AlexaTemperatureSensor(AlexaCapability): "pt-BR", } - def __init__(self, hass, entity): + def __init__(self, hass: HomeAssistant, entity: State) -> None: """Initialize the entity.""" super().__init__(entity) self.hass = hass - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.TemperatureSensor" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "temperature"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "temperature": raise UnsupportedProperty(name) - unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - temp = self.entity.state + unit: str = self.entity.attributes.get( + ATTR_UNIT_OF_MEASUREMENT, self.hass.config.units.temperature_unit + ) + temp: str | None = self.entity.state if self.entity.domain == climate.DOMAIN: unit = self.hass.config.units.temperature_unit temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) - if temp in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None try: - temp = float(temp) + temp_float = float(temp) except ValueError: _LOGGER.warning("Invalid temp value %s for %s", temp, self.entity.entity_id) return None # Alexa displays temperatures with one decimal digit, we don't need to do # rounding for presentation here. - return {"value": temp, "scale": API_TEMP_UNITS[unit]} + return {"value": temp_float, "scale": API_TEMP_UNITS[UnitOfTemperature(unit)]} class AlexaContactSensor(AlexaCapability): @@ -972,28 +978,28 @@ class AlexaContactSensor(AlexaCapability): "pt-BR", } - def __init__(self, hass, entity): + def __init__(self, hass: HomeAssistant, entity: State) -> None: """Initialize the entity.""" super().__init__(entity) self.hass = hass - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.ContactSensor" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "detectionState"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "detectionState": raise UnsupportedProperty(name) @@ -1027,28 +1033,28 @@ class AlexaMotionSensor(AlexaCapability): "pt-BR", } - def __init__(self, hass, entity): + def __init__(self, hass: HomeAssistant, entity: State) -> None: """Initialize the entity.""" super().__init__(entity) self.hass = hass - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.MotionSensor" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "detectionState"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "detectionState": raise UnsupportedProperty(name) @@ -1083,16 +1089,16 @@ class AlexaThermostatController(AlexaCapability): "pt-BR", } - def __init__(self, hass, entity): + def __init__(self, hass: HomeAssistant, entity: State) -> None: """Initialize the entity.""" super().__init__(entity) self.hass = hass - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.ThermostatController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" properties = [{"name": "thermostatMode"}] supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -1103,15 +1109,15 @@ class AlexaThermostatController(AlexaCapability): properties.append({"name": "upperSetpoint"}) return properties - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if self.entity.state == STATE_UNAVAILABLE: return None @@ -1119,13 +1125,13 @@ class AlexaThermostatController(AlexaCapability): if name == "thermostatMode": preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) + mode: dict[str, str] | str | None if preset in API_THERMOSTAT_PRESETS: mode = API_THERMOSTAT_PRESETS[preset] elif self.entity.state == STATE_UNKNOWN: return None else: - mode = API_THERMOSTAT_MODES.get(self.entity.state) - if mode is None: + if self.entity.state not in API_THERMOSTAT_MODES: _LOGGER.error( "%s (%s) has unsupported state value '%s'", self.entity.entity_id, @@ -1133,6 +1139,7 @@ class AlexaThermostatController(AlexaCapability): self.entity.state, ) raise UnsupportedProperty(name) + mode = API_THERMOSTAT_MODES[HVACMode(self.entity.state)] return mode unit = self.hass.config.units.temperature_unit @@ -1158,7 +1165,7 @@ class AlexaThermostatController(AlexaCapability): return {"value": temp, "scale": API_TEMP_UNITS[unit]} - def configuration(self): + def configuration(self) -> dict[str, Any] | None: """Return configuration object. Translates climate HVAC_MODES and PRESETS to supported Alexa @@ -1166,8 +1173,8 @@ class AlexaThermostatController(AlexaCapability): ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM. """ - supported_modes = [] - hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) + supported_modes: list[str] = [] + hvac_modes = self.entity.attributes[climate.ATTR_HVAC_MODES] for mode in hvac_modes: if thermostat_mode := API_THERMOSTAT_MODES.get(mode): supported_modes.append(thermostat_mode) @@ -1181,7 +1188,7 @@ class AlexaThermostatController(AlexaCapability): # Return False for supportsScheduling until supported with event # listener in handler. - configuration = {"supportsScheduling": False} + configuration: dict[str, Any] = {"supportsScheduling": False} if supported_modes: configuration["supportedModes"] = supported_modes @@ -1210,23 +1217,23 @@ class AlexaPowerLevelController(AlexaCapability): "ja-JP", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.PowerLevelController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "powerLevel"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "powerLevel": raise UnsupportedProperty(name) @@ -1255,28 +1262,28 @@ class AlexaSecurityPanelController(AlexaCapability): "pt-BR", } - def __init__(self, hass, entity): + def __init__(self, hass: HomeAssistant, entity: State) -> None: """Initialize the entity.""" super().__init__(entity) self.hass = hass - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.SecurityPanelController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "armState"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "armState": raise UnsupportedProperty(name) @@ -1292,7 +1299,7 @@ class AlexaSecurityPanelController(AlexaCapability): return "ARMED_STAY" return "DISARMED" - def configuration(self): + def configuration(self) -> dict[str, Any] | None: """Return configuration object with supported authorization types.""" code_format = self.entity.attributes.get(ATTR_CODE_FORMAT) supported = self.entity.attributes[ATTR_SUPPORTED_FEATURES] @@ -1350,29 +1357,31 @@ class AlexaModeController(AlexaCapability): "pt-BR", } - def __init__(self, entity, instance, non_controllable=False): + def __init__( + self, entity: State, instance: str, non_controllable: bool = False + ) -> None: """Initialize the entity.""" AlexaCapability.__init__(self, entity, instance, non_controllable) self._resource = None self._semantics = None - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.ModeController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "mode"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "mode": raise UnsupportedProperty(name) @@ -1410,14 +1419,14 @@ class AlexaModeController(AlexaCapability): return None - def configuration(self): + def configuration(self) -> dict[str, Any] | None: """Return configuration with modeResources.""" if isinstance(self._resource, AlexaCapabilityResource): return self._resource.serialize_configuration() return None - def capability_resources(self): + def capability_resources(self) -> dict[str, list[dict[str, Any]]]: """Return capabilityResources object.""" # Fan Direction Resource @@ -1484,9 +1493,9 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() - return None + return {} - def semantics(self): + def semantics(self) -> dict[str, Any] | None: """Build and return semantics object.""" supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -1569,23 +1578,23 @@ class AlexaRangeController(AlexaCapability): self._resource = None self._semantics = None - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.RangeController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "rangeValue"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "rangeValue": raise UnsupportedProperty(name) @@ -1637,14 +1646,14 @@ class AlexaRangeController(AlexaCapability): return None - def configuration(self): + def configuration(self) -> dict[str, Any] | None: """Return configuration with presetResources.""" if isinstance(self._resource, AlexaCapabilityResource): return self._resource.serialize_configuration() return None - def capability_resources(self): + def capability_resources(self) -> dict[str, list[dict[str, Any]]]: """Return capabilityResources object.""" # Fan Speed Percentage Resources @@ -1758,9 +1767,9 @@ class AlexaRangeController(AlexaCapability): return self._resource.serialize_capability_resources() - return None + return {} - def semantics(self): + def semantics(self) -> dict[str, Any] | None: """Build and return semantics object.""" supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -1873,29 +1882,31 @@ class AlexaToggleController(AlexaCapability): "pt-BR", } - def __init__(self, entity, instance, non_controllable=False): + def __init__( + self, entity: State, instance: str, non_controllable: bool = False + ) -> None: """Initialize the entity.""" AlexaCapability.__init__(self, entity, instance, non_controllable) self._resource = None self._semantics = None - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.ToggleController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "toggleState"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "toggleState": raise UnsupportedProperty(name) @@ -1907,7 +1918,7 @@ class AlexaToggleController(AlexaCapability): return None - def capability_resources(self): + def capability_resources(self) -> dict[str, list[dict[str, Any]]]: """Return capabilityResources object.""" # Fan Oscillating Resource @@ -1917,7 +1928,7 @@ class AlexaToggleController(AlexaCapability): ) return self._resource.serialize_capability_resources() - return None + return {} class AlexaChannelController(AlexaCapability): @@ -1945,7 +1956,7 @@ class AlexaChannelController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.ChannelController" @@ -1975,11 +1986,11 @@ class AlexaDoorbellEventSource(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.DoorbellEventSource" - def capability_proactively_reported(self): + def capability_proactively_reported(self) -> bool: """Return True for proactively reported capability.""" return True @@ -2009,23 +2020,23 @@ class AlexaPlaybackStateReporter(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.PlaybackStateReporter" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "playbackState"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "playbackState": raise UnsupportedProperty(name) @@ -2064,7 +2075,7 @@ class AlexaSeekController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.SeekController" @@ -2077,24 +2088,24 @@ class AlexaEventDetectionSensor(AlexaCapability): supported_locales = {"en-US"} - def __init__(self, hass, entity): + def __init__(self, hass: HomeAssistant, entity: State) -> None: """Initialize the entity.""" super().__init__(entity) self.hass = hass - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.EventDetectionSensor" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" return [{"name": "humanPresenceDetectionState"}] - def properties_proactively_reported(self): + def properties_proactively_reported(self) -> bool: """Return True if properties asynchronously reported.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "humanPresenceDetectionState": raise UnsupportedProperty(name) @@ -2119,7 +2130,7 @@ class AlexaEventDetectionSensor(AlexaCapability): return {"value": human_presence} - def configuration(self): + def configuration(self) -> dict[str, Any] | None: """Return supported detection types.""" return { "detectionMethods": ["AUDIO", "VIDEO"], @@ -2165,11 +2176,11 @@ class AlexaEqualizerController(AlexaCapability): "TV", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.EqualizerController" - def properties_supported(self): + def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports. Either bands, mode or both can be specified. Only mode is supported @@ -2177,11 +2188,11 @@ class AlexaEqualizerController(AlexaCapability): """ return [{"name": "mode"}] - def properties_retrievable(self): + def properties_retrievable(self) -> bool: """Return True if properties can be retrieved.""" return True - def get_property(self, name): + def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "mode": raise UnsupportedProperty(name) @@ -2192,7 +2203,7 @@ class AlexaEqualizerController(AlexaCapability): return None - def configurations(self): + def configurations(self) -> dict[str, Any] | None: """Return the sound modes supported in the configurations object.""" configurations = None supported_sound_modes = self.get_valid_inputs( @@ -2204,9 +2215,9 @@ class AlexaEqualizerController(AlexaCapability): return configurations @classmethod - def get_valid_inputs(cls, sound_mode_list): + def get_valid_inputs(cls, sound_mode_list: list[str]) -> list[dict[str, str]]: """Return list of supported inputs.""" - input_list = [] + input_list: list[dict[str, str]] = [] for sound_mode in sound_mode_list: sound_mode = sound_mode.upper() @@ -2224,16 +2235,16 @@ class AlexaTimeHoldController(AlexaCapability): supported_locales = {"en-US"} - def __init__(self, entity, allow_remote_resume=False): + def __init__(self, entity: State, allow_remote_resume: bool = False) -> None: """Initialize the entity.""" super().__init__(entity) self._allow_remote_resume = allow_remote_resume - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.TimeHoldController" - def configuration(self): + def configuration(self) -> dict[str, Any] | None: """Return configuration object. Set allowRemoteResume to True if Alexa can restart the operation on the device. @@ -2267,11 +2278,11 @@ class AlexaCameraStreamController(AlexaCapability): "pt-BR", } - def name(self): + def name(self) -> str: """Return the Alexa API name of this interface.""" return "Alexa.CameraStreamController" - def camera_stream_configurations(self): + def camera_stream_configurations(self) -> list[dict[str, Any]] | None: """Return cameraStreamConfigurations object.""" return [ { diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index d47a548979e..a1ab1d77081 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -4,6 +4,9 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio import logging +from typing import Any + +from yarl import URL from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.storage import Store @@ -33,38 +36,38 @@ class AbstractConfig(ABC): await self._store.async_load() @property - def supports_auth(self): + def supports_auth(self) -> bool: """Return if config supports auth.""" return False @property - def should_report_state(self): + def should_report_state(self) -> bool: """Return if states should be proactively reported.""" return False @property - def endpoint(self): + @abstractmethod + def endpoint(self) -> str | URL | None: """Endpoint for report state.""" - return None @property @abstractmethod - def locale(self): + def locale(self) -> str | None: """Return config locale.""" @property - def entity_config(self): + def entity_config(self) -> dict[str, Any]: """Return entity config.""" return {} @property - def is_reporting_states(self): + def is_reporting_states(self) -> bool: """Return if proactive mode is enabled.""" return self._unsub_proactive_report is not None @callback @abstractmethod - def user_identifier(self): + def user_identifier(self) -> str: """Return an identifier for the user that represents this config.""" async def async_enable_proactive_mode(self) -> None: @@ -85,29 +88,29 @@ class AbstractConfig(ABC): self._unsub_proactive_report = None @callback - def should_expose(self, entity_id): + def should_expose(self, entity_id: str) -> bool: """If an entity should be exposed.""" return False @callback - def async_invalidate_access_token(self): + def async_invalidate_access_token(self) -> None: """Invalidate access token.""" raise NotImplementedError - async def async_get_access_token(self): + async def async_get_access_token(self) -> str | None: """Get an access token.""" raise NotImplementedError - async def async_accept_grant(self, code): + async def async_accept_grant(self, code: str) -> str | None: """Accept a grant.""" raise NotImplementedError @property - def authorized(self): + def authorized(self) -> bool: """Return authorization status.""" return self._store.authorized - async def set_authorized(self, authorized) -> None: + async def set_authorized(self, authorized: bool) -> None: """Set authorization status. - Set when an incoming message is received from Alexa. @@ -132,25 +135,26 @@ class AlexaConfigStore: _STORAGE_VERSION = 1 _STORAGE_KEY = DOMAIN - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize a configuration store.""" - self._data = None + self._data: dict[str, Any] | None = None self._hass = hass - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + self._store: Store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) @property - def authorized(self): + def authorized(self) -> bool: """Return authorization status.""" - return self._data[STORE_AUTHORIZED] + assert self._data is not None + return bool(self._data[STORE_AUTHORIZED]) @callback - def set_authorized(self, authorized): + def set_authorized(self, authorized: bool) -> None: """Set authorization status.""" - if authorized != self._data[STORE_AUTHORIZED]: + if self._data is not None and authorized != self._data[STORE_AUTHORIZED]: self._data[STORE_AUTHORIZED] = authorized self._store.async_delay_save(lambda: self._data, 1.0) - async def async_load(self): + async def async_load(self) -> None: """Load saved configuration from disk.""" if data := await self._store.async_load(): self._data = data diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 9e1c9e589c1..f71bc091106 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -69,7 +69,7 @@ API_TEMP_UNITS = { # Needs to be ordered dict for `async_api_set_thermostat_mode` which does a # reverse mapping of this dict and we want to map the first occurrence of OFF # back to HA state. -API_THERMOSTAT_MODES = OrderedDict( +API_THERMOSTAT_MODES: OrderedDict[str, str] = OrderedDict( [ (climate.HVACMode.HEAT, "HEAT"), (climate.HVACMode.COOL, "COOL"), diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 9a805b43c4f..7f6331515c6 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator, Iterable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from homeassistant.components import ( alarm_control_panel, @@ -274,22 +274,23 @@ class AlexaEntity: self.entity_conf = config.entity_config.get(entity.entity_id, {}) @property - def entity_id(self): + def entity_id(self) -> str: """Return the Entity ID.""" return self.entity.entity_id - def friendly_name(self): + def friendly_name(self) -> str: """Return the Alexa API friendly name.""" - return self.entity_conf.get(CONF_NAME, self.entity.name).translate( - TRANSLATION_TABLE - ) + friendly_name: str = self.entity_conf.get( + CONF_NAME, self.entity.name + ).translate(TRANSLATION_TABLE) + return friendly_name - def description(self): + def description(self) -> str: """Return the Alexa API description.""" description = self.entity_conf.get(CONF_DESCRIPTION) or self.entity_id return f"{description} via Home Assistant".translate(TRANSLATION_TABLE) - def alexa_id(self): + def alexa_id(self) -> str: """Return the Alexa API entity id.""" return generate_alexa_id(self.entity.entity_id) @@ -317,7 +318,7 @@ class AlexaEntity: """ raise NotImplementedError - def serialize_properties(self): + def serialize_properties(self) -> Generator[dict[str, Any], None, None]: """Yield each supported property in API format.""" for interface in self.interfaces(): if not interface.properties_proactively_reported(): @@ -325,9 +326,9 @@ class AlexaEntity: yield from interface.serialize_properties() - def serialize_discovery(self): + def serialize_discovery(self) -> dict[str, Any]: """Serialize the entity for discovery.""" - result = { + result: dict[str, Any] = { "displayCategories": self.display_categories(), "cookie": {}, "endpointId": self.alexa_id(), @@ -366,7 +367,7 @@ def async_get_entities( hass: HomeAssistant, config: AbstractConfig ) -> list[AlexaEntity]: """Return all entities that are supported by Alexa.""" - entities = [] + entities: list[AlexaEntity] = [] for state in hass.states.async_all(): if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: continue @@ -725,7 +726,7 @@ class MediaPlayerCapabilities(AlexaEntity): class SceneCapabilities(AlexaEntity): """Class to represent Scene capabilities.""" - def description(self): + def description(self) -> str: """Return the Alexa API description.""" description = AlexaEntity.description(self) if "scene" not in description.casefold(): diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 7f4b41b9ec7..2c5ced62403 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -1,8 +1,9 @@ """Alexa related errors.""" from __future__ import annotations -from typing import Literal +from typing import Any, Literal +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import API_TEMP_UNITS @@ -29,7 +30,9 @@ class AlexaError(Exception): namespace: str | None = None error_type: str | None = None - def __init__(self, error_message, payload=None): + def __init__( + self, error_message: str, payload: dict[str, Any] | None = None + ) -> None: """Initialize an alexa error.""" Exception.__init__(self) self.error_message = error_message @@ -42,7 +45,7 @@ class AlexaInvalidEndpointError(AlexaError): namespace = "Alexa" error_type = "NO_SUCH_ENDPOINT" - def __init__(self, endpoint_id): + def __init__(self, endpoint_id: str) -> None: """Initialize invalid endpoint error.""" msg = f"The endpoint {endpoint_id} does not exist" AlexaError.__init__(self, msg) @@ -93,7 +96,9 @@ class AlexaTempRangeError(AlexaError): namespace = "Alexa" error_type = "TEMPERATURE_VALUE_OUT_OF_RANGE" - def __init__(self, hass, temp, min_temp, max_temp): + def __init__( + self, hass: HomeAssistant, temp: float, min_temp: float, max_temp: float + ) -> None: """Initialize TempRange error.""" unit = hass.config.units.temperature_unit temp_range = { diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 6f53d86d444..3361908ce9a 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -4,10 +4,13 @@ from http import HTTPStatus import logging import uuid +from aiohttp.web_response import StreamResponse + from homeassistant.components import http from homeassistant.const import CONF_PASSWORD -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import template +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .const import ( @@ -32,7 +35,7 @@ FLASH_BRIEFINGS_API_ENDPOINT = "/api/alexa/flash_briefings/{briefing_id}" @callback -def async_setup(hass, flash_briefing_config): +def async_setup(hass: HomeAssistant, flash_briefing_config: ConfigType) -> None: """Activate Alexa component.""" hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefing_config)) @@ -44,14 +47,16 @@ class AlexaFlashBriefingView(http.HomeAssistantView): requires_auth = False name = "api:alexa:flash_briefings" - def __init__(self, hass, flash_briefings): + def __init__(self, hass: HomeAssistant, flash_briefings: ConfigType) -> None: """Initialize Alexa view.""" super().__init__() self.flash_briefings = flash_briefings template.attach(hass, self.flash_briefings) @callback - def get(self, request, briefing_id): + def get( + self, request: http.HomeAssistantRequest, briefing_id: str + ) -> StreamResponse | tuple[bytes, HTTPStatus]: """Handle Alexa Flash Briefing request.""" _LOGGER.debug("Received Alexa flash briefing request for: %s", briefing_id) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index c1b99b017e5..3e995e9ffe2 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -75,8 +75,7 @@ from .errors import ( AlexaUnsupportedThermostatModeError, AlexaVideoActionNotPermittedForContentError, ) -from .messages import AlexaDirective, AlexaResponse -from .state_report import async_enable_proactive_mode +from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode _LOGGER = logging.getLogger(__name__) DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive" @@ -124,7 +123,7 @@ async def async_api_accept_grant( Async friendly. """ - auth_code = directive.payload["grant"]["code"] + auth_code: str = directive.payload["grant"]["code"] _LOGGER.debug("AcceptGrant code: %s", auth_code) if config.supports_auth: @@ -340,8 +339,8 @@ async def async_api_decrease_color_temp( ) -> AlexaResponse: """Process a decrease color temperature request.""" entity = directive.entity - current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) - max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS)) + current = int(entity.attributes[light.ATTR_COLOR_TEMP]) + max_mireds = int(entity.attributes[light.ATTR_MAX_MIREDS]) value = min(max_mireds, current + 50) await hass.services.async_call( @@ -364,8 +363,8 @@ async def async_api_increase_color_temp( ) -> AlexaResponse: """Process an increase color temperature request.""" entity = directive.entity - current = int(entity.attributes.get(light.ATTR_COLOR_TEMP)) - min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS)) + current = int(entity.attributes[light.ATTR_COLOR_TEMP]) + min_mireds = int(entity.attributes[light.ATTR_MIN_MIREDS]) value = max(min_mireds, current - 50) await hass.services.async_call( @@ -404,7 +403,7 @@ async def async_api_activate( context=context, ) - payload = { + payload: dict[str, Any] = { "cause": {"type": Cause.VOICE_INTERACTION}, "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } @@ -433,7 +432,7 @@ async def async_api_deactivate( context=context, ) - payload = { + payload: dict[str, Any] = { "cause": {"type": Cause.VOICE_INTERACTION}, "timestamp": dt_util.utcnow().strftime(DATE_FORMAT), } @@ -475,7 +474,24 @@ async def async_api_unlock( context: ha.Context, ) -> AlexaResponse: """Process an unlock request.""" - if config.locale not in {"de-DE", "en-US", "ja-JP"}: + if config.locale not in { + "ar-SA", + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "es-US", + "fr-CA", + "fr-FR", + "hi-IN", + "it-IT", + "ja-JP", + "pt-BR", + }: msg = ( "The unlock directive is not supported for the following locales:" f" {config.locale}" @@ -510,7 +526,7 @@ async def async_api_set_volume( volume = round(float(directive.payload["volume"] / 100), 2) entity = directive.entity - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, } @@ -555,7 +571,7 @@ async def async_api_select_input( ) raise AlexaInvalidValueError(msg) - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, media_player.const.ATTR_INPUT_SOURCE: media_input, } @@ -582,7 +598,7 @@ async def async_api_adjust_volume( volume_delta = int(directive.payload["volume"]) entity = directive.entity - current_level = entity.attributes.get(media_player.const.ATTR_MEDIA_VOLUME_LEVEL) + current_level = entity.attributes[media_player.const.ATTR_MEDIA_VOLUME_LEVEL] # read current state try: @@ -592,7 +608,7 @@ async def async_api_adjust_volume( volume = float(max(0, volume_delta + current) / 100) - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume, } @@ -632,7 +648,7 @@ async def async_api_adjust_volume_step( if is_default: volume_int = default_steps - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} for _ in range(abs(volume_int)): await hass.services.async_call( @@ -653,7 +669,7 @@ async def async_api_set_mute( """Process a set mute request.""" mute = bool(directive.payload["mute"]) entity = directive.entity - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute, } @@ -674,7 +690,7 @@ async def async_api_play( ) -> AlexaResponse: """Process a play request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} await hass.services.async_call( entity.domain, SERVICE_MEDIA_PLAY, data, blocking=False, context=context @@ -692,7 +708,7 @@ async def async_api_pause( ) -> AlexaResponse: """Process a pause request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} await hass.services.async_call( entity.domain, SERVICE_MEDIA_PAUSE, data, blocking=False, context=context @@ -710,7 +726,7 @@ async def async_api_stop( ) -> AlexaResponse: """Process a stop request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} await hass.services.async_call( entity.domain, SERVICE_MEDIA_STOP, data, blocking=False, context=context @@ -728,7 +744,7 @@ async def async_api_next( ) -> AlexaResponse: """Process a next request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} await hass.services.async_call( entity.domain, SERVICE_MEDIA_NEXT_TRACK, data, blocking=False, context=context @@ -746,7 +762,7 @@ async def async_api_previous( ) -> AlexaResponse: """Process a previous request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} await hass.services.async_call( entity.domain, @@ -759,7 +775,9 @@ async def async_api_previous( return directive.response() -def temperature_from_object(hass, temp_obj, interval=False): +def temperature_from_object( + hass: ha.HomeAssistant, temp_obj: dict[str, Any], interval: bool = False +) -> float: """Get temperature from Temperature object in requested unit.""" to_unit = hass.config.units.temperature_unit from_unit = UnitOfTemperature.CELSIUS @@ -785,11 +803,11 @@ async def async_api_set_target_temp( ) -> AlexaResponse: """Process a set target temperature request.""" entity = directive.entity - min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) - max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + min_temp = entity.attributes[climate.ATTR_MIN_TEMP] + max_temp = entity.attributes[climate.ATTR_MAX_TEMP] unit = hass.config.units.temperature_unit - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} payload = directive.payload response = directive.response() @@ -849,9 +867,10 @@ async def async_api_adjust_target_temp( context: ha.Context, ) -> AlexaResponse: """Process an adjust target temperature request.""" + data: dict[str, Any] entity = directive.entity - min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP) - max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP) + min_temp = entity.attributes[climate.ATTR_MIN_TEMP] + max_temp = entity.attributes[climate.ATTR_MAX_TEMP] unit = hass.config.units.temperature_unit temp_delta = temperature_from_object( @@ -862,7 +881,7 @@ async def async_api_adjust_target_temp( current_target_temp_high = entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) current_target_temp_low = entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) - if current_target_temp_high and current_target_temp_low: + if current_target_temp_high is not None and current_target_temp_low is not None: target_temp_high = float(current_target_temp_high) + temp_delta if target_temp_high < min_temp or target_temp_high > max_temp: raise AlexaTempRangeError(hass, target_temp_high, min_temp, max_temp) @@ -892,7 +911,7 @@ async def async_api_adjust_target_temp( } ) else: - target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta + target_temp = float(entity.attributes[ATTR_TEMPERATURE]) + temp_delta if target_temp < min_temp or target_temp > max_temp: raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) @@ -925,11 +944,13 @@ async def async_api_set_thermostat_mode( context: ha.Context, ) -> AlexaResponse: """Process a set thermostat mode request.""" + operation_list: list[str] + entity = directive.entity mode = directive.payload["thermostatMode"] mode = mode if isinstance(mode, str) else mode["value"] - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None) @@ -944,7 +965,7 @@ async def async_api_set_thermostat_mode( data[climate.ATTR_PRESET_MODE] = ha_preset elif mode == "CUSTOM": - operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES, []) custom_mode = directive.payload["thermostatMode"]["customName"] custom_mode = next( (k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode), @@ -960,9 +981,13 @@ async def async_api_set_thermostat_mode( data[climate.ATTR_HVAC_MODE] = custom_mode else: - operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) - ha_modes = {k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode} - ha_mode = next(iter(set(ha_modes).intersection(operation_list)), None) + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES, []) + ha_modes: dict[str, str] = { + k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode + } + ha_mode: str | None = next( + iter(set(ha_modes).intersection(operation_list)), None + ) if ha_mode not in operation_list: msg = f"The requested thermostat mode {mode} is not supported" raise AlexaUnsupportedThermostatModeError(msg) @@ -1007,7 +1032,7 @@ async def async_api_arm( entity = directive.entity service = None arm_state = directive.payload["armState"] - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} if entity.state != STATE_ALARM_DISARMED: msg = "You must disarm the system before you can set the requested arm state." @@ -1027,7 +1052,7 @@ async def async_api_arm( ) # return 0 until alarm integration supports an exit delay - payload = {"exitDelayInSeconds": 0} + payload: dict[str, Any] = {"exitDelayInSeconds": 0} response = directive.response( name="Arm.Response", namespace="Alexa.SecurityPanelController", payload=payload @@ -1053,7 +1078,7 @@ async def async_api_disarm( ) -> AlexaResponse: """Process a Security Panel Disarm request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} response = directive.response() # Per Alexa Documentation: If you receive a Disarm directive, and the @@ -1095,7 +1120,7 @@ async def async_api_set_mode( instance = directive.instance domain = entity.domain service = None - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} mode = directive.payload["mode"] # Fan Direction @@ -1108,8 +1133,11 @@ async def async_api_set_mode( # Fan preset_mode elif instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}": preset_mode = mode.split(".")[1] - if preset_mode != PRESET_MODE_NA and preset_mode in entity.attributes.get( - fan.ATTR_PRESET_MODES + preset_modes: list[str] | None = entity.attributes.get(fan.ATTR_PRESET_MODES) + if ( + preset_mode != PRESET_MODE_NA + and preset_modes + and preset_mode in preset_modes ): service = fan.SERVICE_SET_PRESET_MODE data[fan.ATTR_PRESET_MODE] = preset_mode @@ -1120,9 +1148,8 @@ async def async_api_set_mode( # Humidifier mode elif instance == f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}": mode = mode.split(".")[1] - if mode != PRESET_MODE_NA and mode in entity.attributes.get( - humidifier.ATTR_AVAILABLE_MODES - ): + modes: list[str] | None = entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) + if mode != PRESET_MODE_NA and modes and mode in modes: service = humidifier.SERVICE_SET_MODE data[humidifier.ATTR_MODE] = mode else: @@ -1195,7 +1222,7 @@ async def async_api_toggle_on( raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) service = fan.SERVICE_OSCILLATE - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, fan.ATTR_OSCILLATING: True, } @@ -1234,7 +1261,7 @@ async def async_api_toggle_off( raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) service = fan.SERVICE_OSCILLATE - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, fan.ATTR_OSCILLATING: False, } @@ -1268,7 +1295,7 @@ async def async_api_set_range( instance = directive.instance domain = entity.domain service = None - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] # Cover Position @@ -1537,7 +1564,7 @@ async def async_api_changechannel( channel = metadata_payload["name"] payload_name = "callSign" - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, media_player.const.ATTR_MEDIA_CONTENT_ID: channel, media_player.const.ATTR_MEDIA_CONTENT_TYPE: ( @@ -1577,7 +1604,7 @@ async def async_api_skipchannel( channel = int(directive.payload["channelCount"]) entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} if channel < 0: service_media = SERVICE_MEDIA_PREVIOUS_TRACK @@ -1624,7 +1651,7 @@ async def async_api_seek( if media_duration and 0 < int(media_duration) < seek_position: seek_position = media_duration - data = { + data: dict[str, Any] = { ATTR_ENTITY_ID: entity.entity_id, media_player.ATTR_MEDIA_SEEK_POSITION: seek_position, } @@ -1640,7 +1667,9 @@ async def async_api_seek( # convert seconds to milliseconds for StateReport. seek_position = int(seek_position * 1000) - payload = {"properties": [{"name": "positionMilliseconds", "value": seek_position}]} + payload: dict[str, Any] = { + "properties": [{"name": "positionMilliseconds", "value": seek_position}] + } return directive.response( name="StateReport", namespace="Alexa.SeekController", payload=payload ) @@ -1656,7 +1685,7 @@ async def async_api_set_eq_mode( """Process a SetMode request for EqualizerController.""" mode = directive.payload["mode"] entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) if sound_mode_list and mode.lower() in sound_mode_list: @@ -1702,7 +1731,7 @@ async def async_api_hold( ) -> AlexaResponse: """Process a TimeHoldController Hold request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == timer.DOMAIN: service = timer.SERVICE_PAUSE @@ -1729,7 +1758,7 @@ async def async_api_resume( ) -> AlexaResponse: """Process a TimeHoldController Resume request.""" entity = directive.entity - data = {ATTR_ENTITY_ID: entity.entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == timer.DOMAIN: service = timer.SERVICE_START @@ -1774,7 +1803,7 @@ async def async_api_initialize_camera_stream( "Failed to find suitable URL to serve to Alexa" ) from err - payload = { + payload: dict[str, Any] = { "cameraStreams": [ { "uri": f"{external_url}{stream_source}", diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 06f76b8806e..58319dd44b5 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -3,8 +3,10 @@ import enum import logging from typing import Any +from aiohttp.web import Response + from homeassistant.components import http -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent from homeassistant.util.decorator import Registry @@ -18,7 +20,7 @@ HANDLERS = Registry() # type: ignore[var-annotated] INTENTS_API_ENDPOINT = "/api/alexa" -class SpeechType(enum.Enum): +class SpeechType(enum.StrEnum): """The Alexa speech types.""" plaintext = "PlainText" @@ -28,7 +30,7 @@ class SpeechType(enum.Enum): SPEECH_MAPPINGS = {"plain": SpeechType.plaintext, "ssml": SpeechType.ssml} -class CardType(enum.Enum): +class CardType(enum.StrEnum): """The Alexa card types.""" simple = "Simple" @@ -36,12 +38,12 @@ class CardType(enum.Enum): @callback -def async_setup(hass): +def async_setup(hass: HomeAssistant) -> None: """Activate Alexa component.""" hass.http.register_view(AlexaIntentsView) -async def async_setup_intents(hass): +async def async_setup_intents(hass: HomeAssistant) -> None: """Do intents setup. Right now this module does not expose any, but the intent component breaks @@ -60,15 +62,15 @@ class AlexaIntentsView(http.HomeAssistantView): url = INTENTS_API_ENDPOINT name = "api:alexa" - async def post(self, request): + async def post(self, request: http.HomeAssistantRequest) -> Response | bytes: """Handle Alexa.""" - hass = request.app["hass"] - message = await request.json() + hass: HomeAssistant = request.app["hass"] + message: dict[str, Any] = await request.json() _LOGGER.debug("Received Alexa request: %s", message) try: - response = await async_handle_message(hass, message) + response: dict[str, Any] = await async_handle_message(hass, message) return b"" if response is None else self.json(response) except UnknownRequest as err: _LOGGER.warning(str(err)) @@ -99,15 +101,19 @@ class AlexaIntentsView(http.HomeAssistantView): ) -def intent_error_response(hass, message, error): +def intent_error_response( + hass: HomeAssistant, message: dict[str, Any], error: str +) -> dict[str, Any]: """Return an Alexa response that will speak the error message.""" - alexa_intent_info = message.get("request").get("intent") - alexa_response = AlexaResponse(hass, alexa_intent_info) + alexa_intent_info = message["request"].get("intent") + alexa_response = AlexaIntentResponse(hass, alexa_intent_info) alexa_response.add_speech(SpeechType.plaintext, error) return alexa_response.as_dict() -async def async_handle_message(hass, message): +async def async_handle_message( + hass: HomeAssistant, message: dict[str, Any] +) -> dict[str, Any]: """Handle an Alexa intent. Raises: @@ -117,19 +123,22 @@ async def async_handle_message(hass, message): - intent.IntentError """ - req = message.get("request") + req = message["request"] req_type = req["type"] if not (handler := HANDLERS.get(req_type)): raise UnknownRequest(f"Received unknown request {req_type}") - return await handler(hass, message) + response: dict[str, Any] = await handler(hass, message) + return response @HANDLERS.register("SessionEndedRequest") @HANDLERS.register("IntentRequest") @HANDLERS.register("LaunchRequest") -async def async_handle_intent(hass, message): +async def async_handle_intent( + hass: HomeAssistant, message: dict[str, Any] +) -> dict[str, Any]: """Handle an intent request. Raises: @@ -138,9 +147,9 @@ async def async_handle_intent(hass, message): - intent.IntentError """ - req = message.get("request") + req = message["request"] alexa_intent_info = req.get("intent") - alexa_response = AlexaResponse(hass, alexa_intent_info) + alexa_response = AlexaIntentResponse(hass, alexa_intent_info) if req["type"] == "LaunchRequest": intent_name = ( @@ -187,7 +196,7 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]: # passes the id and name of the nearest possible slot resolution. For # reference to the request object structure, see the Alexa docs: # https://tinyurl.com/ybvm7jhs - resolved_data = {} + resolved_data: dict[str, Any] = {} resolved_data["value"] = request["value"] resolved_data["id"] = "" @@ -226,18 +235,18 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]: return resolved_data -class AlexaResponse: +class AlexaIntentResponse: """Help generating the response for Alexa.""" - def __init__(self, hass, intent_info): + def __init__(self, hass: HomeAssistant, intent_info: dict[str, Any] | None) -> None: """Initialize the response.""" self.hass = hass - self.speech = None - self.card = None - self.reprompt = None - self.session_attributes = {} + self.speech: dict[str, Any] | None = None + self.card: dict[str, Any] | None = None + self.reprompt: dict[str, Any] | None = None + self.session_attributes: dict[str, Any] = {} self.should_end_session = True - self.variables = {} + self.variables: dict[str, Any] = {} # Intent is None if request was a LaunchRequest or SessionEndedRequest if intent_info is not None: @@ -252,7 +261,7 @@ class AlexaResponse: self.variables[_key] = _slot_data["value"] self.variables[_key + "_Id"] = _slot_data["id"] - def add_card(self, card_type, title, content): + def add_card(self, card_type: CardType, title: str, content: str) -> None: """Add a card to the response.""" assert self.card is None @@ -266,7 +275,7 @@ class AlexaResponse: card["content"] = content self.card = card - def add_speech(self, speech_type, text): + def add_speech(self, speech_type: SpeechType, text: str) -> None: """Add speech to the response.""" assert self.speech is None @@ -274,7 +283,7 @@ class AlexaResponse: self.speech = {"type": speech_type.value, key: text} - def add_reprompt(self, speech_type, text): + def add_reprompt(self, speech_type: SpeechType, text: str) -> None: """Add reprompt if user does not answer.""" assert self.reprompt is None @@ -284,9 +293,9 @@ class AlexaResponse: self.reprompt = {"type": speech_type.value, key: text} - def as_dict(self): + def as_dict(self) -> dict[str, Any]: """Return response in an Alexa valid dict.""" - response = {"shouldEndSession": self.should_end_session} + response: dict[str, Any] = {"shouldEndSession": self.should_end_session} if self.card is not None: response["card"] = self.card diff --git a/homeassistant/components/alexa/logbook.py b/homeassistant/components/alexa/logbook.py index 496989c57de..cb6835c7ba5 100644 --- a/homeassistant/components/alexa/logbook.py +++ b/homeassistant/components/alexa/logbook.py @@ -1,20 +1,26 @@ """Describe logbook events.""" +from collections.abc import Callable +from typing import Any + from homeassistant.components.logbook import ( LOGBOOK_ENTRY_ENTITY_ID, LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME, ) -from homeassistant.core import callback +from homeassistant.core import Event, HomeAssistant, callback from .const import DOMAIN, EVENT_ALEXA_SMART_HOME @callback -def async_describe_events(hass, async_describe_event): +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: """Describe logbook events.""" @callback - def async_describe_logbook_event(event): + def async_describe_logbook_event(event: Event) -> dict[str, Any]: """Describe a logbook event.""" data = event.data diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py deleted file mode 100644 index 4dd154ea11f..00000000000 --- a/homeassistant/components/alexa/messages.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Alexa models.""" -import logging -from uuid import uuid4 - -from .const import ( - API_CONTEXT, - API_DIRECTIVE, - API_ENDPOINT, - API_EVENT, - API_HEADER, - API_PAYLOAD, - API_SCOPE, -) -from .entities import ENTITY_ADAPTERS -from .errors import AlexaInvalidEndpointError - -_LOGGER = logging.getLogger(__name__) - - -class AlexaDirective: - """An incoming Alexa directive.""" - - def __init__(self, request): - """Initialize a directive.""" - self._directive = request[API_DIRECTIVE] - self.namespace = self._directive[API_HEADER]["namespace"] - self.name = self._directive[API_HEADER]["name"] - self.payload = self._directive[API_PAYLOAD] - self.has_endpoint = API_ENDPOINT in self._directive - - self.entity = self.entity_id = self.endpoint = self.instance = None - - def load_entity(self, hass, config): - """Set attributes related to the entity for this request. - - Sets these attributes when self.has_endpoint is True: - - - entity - - entity_id - - endpoint - - instance (when header includes instance property) - - Behavior when self.has_endpoint is False is undefined. - - Will raise AlexaInvalidEndpointError if the endpoint in the request is - malformed or nonexistent. - """ - _endpoint_id = self._directive[API_ENDPOINT]["endpointId"] - self.entity_id = _endpoint_id.replace("#", ".") - - self.entity = hass.states.get(self.entity_id) - if not self.entity or not config.should_expose(self.entity_id): - raise AlexaInvalidEndpointError(_endpoint_id) - - self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity) - if "instance" in self._directive[API_HEADER]: - self.instance = self._directive[API_HEADER]["instance"] - - def response(self, name="Response", namespace="Alexa", payload=None): - """Create an API formatted response. - - Async friendly. - """ - response = AlexaResponse(name, namespace, payload) - - token = self._directive[API_HEADER].get("correlationToken") - if token: - response.set_correlation_token(token) - - if self.has_endpoint: - response.set_endpoint(self._directive[API_ENDPOINT].copy()) - - return response - - def error( - self, - namespace="Alexa", - error_type="INTERNAL_ERROR", - error_message="", - payload=None, - ): - """Create a API formatted error response. - - Async friendly. - """ - payload = payload or {} - payload["type"] = error_type - payload["message"] = error_message - - _LOGGER.info( - "Request %s/%s error %s: %s", - self._directive[API_HEADER]["namespace"], - self._directive[API_HEADER]["name"], - error_type, - error_message, - ) - - return self.response(name="ErrorResponse", namespace=namespace, payload=payload) - - -class AlexaResponse: - """Class to hold a response.""" - - def __init__(self, name, namespace, payload=None): - """Initialize the response.""" - payload = payload or {} - self._response = { - API_EVENT: { - API_HEADER: { - "namespace": namespace, - "name": name, - "messageId": str(uuid4()), - "payloadVersion": "3", - }, - API_PAYLOAD: payload, - } - } - - @property - def name(self): - """Return the name of this response.""" - return self._response[API_EVENT][API_HEADER]["name"] - - @property - def namespace(self): - """Return the namespace of this response.""" - return self._response[API_EVENT][API_HEADER]["namespace"] - - def set_correlation_token(self, token): - """Set the correlationToken. - - This should normally mirror the value from a request, and is set by - AlexaDirective.response() usually. - """ - self._response[API_EVENT][API_HEADER]["correlationToken"] = token - - def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None): - """Set the endpoint dictionary. - - This is used to send proactive messages to Alexa. - """ - self._response[API_EVENT][API_ENDPOINT] = { - API_SCOPE: {"type": "BearerToken", "token": bearer_token} - } - - if endpoint_id is not None: - self._response[API_EVENT][API_ENDPOINT]["endpointId"] = endpoint_id - - if cookie is not None: - self._response[API_EVENT][API_ENDPOINT]["cookie"] = cookie - - def set_endpoint(self, endpoint): - """Set the endpoint. - - This should normally mirror the value from a request, and is set by - AlexaDirective.response() usually. - """ - self._response[API_EVENT][API_ENDPOINT] = endpoint - - def _properties(self): - context = self._response.setdefault(API_CONTEXT, {}) - return context.setdefault("properties", []) - - def add_context_property(self, prop): - """Add a property to the response context. - - The Alexa response includes a list of properties which provides - feedback on how states have changed. For example if a user asks, - "Alexa, set thermostat to 20 degrees", the API expects a response with - the new value of the property, and Alexa will respond to the user - "Thermostat set to 20 degrees". - - async_handle_message() will call .merge_context_properties() for every - request automatically, however often handlers will call services to - change state but the effects of those changes are applied - asynchronously. Thus, handlers should call this method to confirm - changes before returning. - """ - self._properties().append(prop) - - def merge_context_properties(self, endpoint): - """Add all properties from given endpoint if not already set. - - Handlers should be using .add_context_property(). - """ - properties = self._properties() - already_set = {(p["namespace"], p["name"]) for p in properties} - - for prop in endpoint.serialize_properties(): - if (prop["namespace"], prop["name"]) not in already_set: - self.add_context_property(prop) - - def serialize(self): - """Return response as a JSON-able data structure.""" - return self._response diff --git a/homeassistant/components/alexa/resources.py b/homeassistant/components/alexa/resources.py index e171cf0ebdc..3606c5401ee 100644 --- a/homeassistant/components/alexa/resources.py +++ b/homeassistant/components/alexa/resources.py @@ -1,6 +1,9 @@ """Alexa Resources and Assets.""" +from typing import Any + + class AlexaGlobalCatalog: """The Global Alexa catalog. @@ -207,36 +210,40 @@ class AlexaCapabilityResource: https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources """ - def __init__(self, labels): + def __init__(self, labels: list[str]) -> None: """Initialize an Alexa resource.""" self._resource_labels = [] for label in labels: self._resource_labels.append(label) - def serialize_capability_resources(self): + def serialize_capability_resources(self) -> dict[str, list[dict[str, Any]]]: """Return capabilityResources object serialized for an API response.""" return self.serialize_labels(self._resource_labels) - def serialize_configuration(self): + def serialize_configuration(self) -> dict[str, Any]: """Return serialized configuration for an API response. Return ModeResources, PresetResources friendlyNames serialized. """ - return [] + raise NotImplementedError() - def serialize_labels(self, resources): + def serialize_labels(self, resources: list[str]) -> dict[str, list[dict[str, Any]]]: """Return serialized labels for an API response. Returns resource label objects for friendlyNames serialized. """ - labels = [] + labels: list[dict[str, Any]] = [] + label_dict: dict[str, Any] for label in resources: if label in AlexaGlobalCatalog.__dict__.values(): - label = {"@type": "asset", "value": {"assetId": label}} + label_dict = {"@type": "asset", "value": {"assetId": label}} else: - label = {"@type": "text", "value": {"text": label, "locale": "en-US"}} + label_dict = { + "@type": "text", + "value": {"text": label, "locale": "en-US"}, + } - labels.append(label) + labels.append(label_dict) return {"friendlyNames": labels} @@ -247,22 +254,22 @@ class AlexaModeResource(AlexaCapabilityResource): https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources """ - def __init__(self, labels, ordered=False): + def __init__(self, labels: list[str], ordered: bool = False) -> None: """Initialize an Alexa modeResource.""" super().__init__(labels) - self._supported_modes = [] - self._mode_ordered = ordered + self._supported_modes: list[dict[str, Any]] = [] + self._mode_ordered: bool = ordered - def add_mode(self, value, labels): + def add_mode(self, value: str, labels: list[str]) -> None: """Add mode to the supportedModes object.""" self._supported_modes.append({"value": value, "labels": labels}) - def serialize_configuration(self): + def serialize_configuration(self) -> dict[str, Any]: """Return serialized configuration for an API response. Returns configuration for ModeResources friendlyNames serialized. """ - mode_resources = [] + mode_resources: list[dict[str, Any]] = [] for mode in self._supported_modes: result = { "value": mode["value"], @@ -282,10 +289,17 @@ class AlexaPresetResource(AlexaCapabilityResource): https://developer.amazon.com/docs/device-apis/resources-and-assets.html#presetresources """ - def __init__(self, labels, min_value, max_value, precision, unit=None): + def __init__( + self, + labels: list[str], + min_value: int | float, + max_value: int | float, + precision: int | float, + unit: str | None = None, + ) -> None: """Initialize an Alexa presetResource.""" super().__init__(labels) - self._presets = [] + self._presets: list[dict[str, Any]] = [] self._minimum_value = min_value self._maximum_value = max_value self._precision = precision @@ -293,16 +307,16 @@ class AlexaPresetResource(AlexaCapabilityResource): if unit in AlexaGlobalCatalog.__dict__.values(): self._unit_of_measure = unit - def add_preset(self, value, labels): + def add_preset(self, value: int | float, labels: list[str]) -> None: """Add preset to configuration presets array.""" self._presets.append({"value": value, "labels": labels}) - def serialize_configuration(self): + def serialize_configuration(self) -> dict[str, Any]: """Return serialized configuration for an API response. Returns configuration for PresetResources friendlyNames serialized. """ - configuration = { + configuration: dict[str, Any] = { "supportedRange": { "minimumValue": self._minimum_value, "maximumValue": self._maximum_value, @@ -372,26 +386,28 @@ class AlexaSemantics: DIRECTIVE_MODE_SET_MODE = "SetMode" DIRECTIVE_MODE_ADJUST_MODE = "AdjustMode" - def __init__(self): + def __init__(self) -> None: """Initialize an Alexa modeResource.""" - self._action_mappings = [] - self._state_mappings = [] + self._action_mappings: list[dict[str, Any]] = [] + self._state_mappings: list[dict[str, Any]] = [] - def _add_action_mapping(self, semantics): + def _add_action_mapping(self, semantics: dict[str, Any]) -> None: """Add action mapping between actions and interface directives.""" self._action_mappings.append(semantics) - def _add_state_mapping(self, semantics): + def _add_state_mapping(self, semantics: dict[str, Any]) -> None: """Add state mapping between states and interface directives.""" self._state_mappings.append(semantics) - def add_states_to_value(self, states, value): + def add_states_to_value(self, states: list[str], value: Any) -> None: """Add StatesToValue stateMappings.""" self._add_state_mapping( {"@type": self.STATES_TO_VALUE, "states": states, "value": value} ) - def add_states_to_range(self, states, min_value, max_value): + def add_states_to_range( + self, states: list[str], min_value: int | float, max_value: int | float + ) -> None: """Add StatesToRange stateMappings.""" self._add_state_mapping( { @@ -401,7 +417,9 @@ class AlexaSemantics: } ) - def add_action_to_directive(self, actions, directive, payload): + def add_action_to_directive( + self, actions: list[str], directive: str, payload: dict[str, Any] + ) -> None: """Add ActionsToDirective actionMappings.""" self._add_action_mapping( { @@ -411,9 +429,9 @@ class AlexaSemantics: } ) - def serialize_semantics(self): + def serialize_semantics(self) -> dict[str, Any]: """Return semantics object serialized for an API response.""" - semantics = {} + semantics: dict[str, Any] = {} if self._action_mappings: semantics[self.MAPPINGS_ACTION] = self._action_mappings if self._state_mappings: diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 24229507877..a8101896116 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,17 +1,170 @@ """Support for alexa Smart Home Skill API.""" import logging +from typing import Any -import homeassistant.core as ha +from aiohttp import web +from yarl import URL -from .const import API_DIRECTIVE, API_HEADER, EVENT_ALEXA_SMART_HOME +from homeassistant import core +from homeassistant.auth.models import User +from homeassistant.components.http import HomeAssistantRequest +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.typing import ConfigType + +from .auth import Auth +from .config import AbstractConfig +from .const import ( + API_DIRECTIVE, + API_HEADER, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER, + CONF_LOCALE, + EVENT_ALEXA_SMART_HOME, +) from .errors import AlexaBridgeUnreachableError, AlexaError from .handlers import HANDLERS -from .messages import AlexaDirective +from .state_report import AlexaDirective _LOGGER = logging.getLogger(__name__) +SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home" -async def async_handle_message(hass, config, request, context=None, enabled=True): +class AlexaConfig(AbstractConfig): + """Alexa config.""" + + _auth: Auth | None + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize Alexa config.""" + super().__init__(hass) + self._config = config + + if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): + self._auth = Auth(hass, config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET]) + else: + self._auth = None + + @property + def supports_auth(self) -> bool: + """Return if config supports auth.""" + return self._auth is not None + + @property + def should_report_state(self) -> bool: + """Return if we should proactively report states.""" + return self._auth is not None and self.authorized + + @property + def endpoint(self) -> str | URL | None: + """Endpoint for report state.""" + return self._config.get(CONF_ENDPOINT) + + @property + def entity_config(self) -> dict[str, Any]: + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + @property + def locale(self) -> str | None: + """Return config locale.""" + return self._config.get(CONF_LOCALE) + + @core.callback + def user_identifier(self) -> str: + """Return an identifier for the user that represents this config.""" + return "" + + @core.callback + def should_expose(self, entity_id: str) -> bool: + """If an entity should be exposed.""" + if not self._config[CONF_FILTER].empty_filter: + return bool(self._config[CONF_FILTER](entity_id)) + + entity_registry = er.async_get(self.hass) + if registry_entry := entity_registry.async_get(entity_id): + auxiliary_entity = ( + registry_entry.entity_category is not None + or registry_entry.hidden_by is not None + ) + else: + auxiliary_entity = False + return not auxiliary_entity + + @core.callback + def async_invalidate_access_token(self) -> None: + """Invalidate access token.""" + assert self._auth is not None + self._auth.async_invalidate_access_token() + + async def async_get_access_token(self) -> str | None: + """Get an access token.""" + assert self._auth is not None + return await self._auth.async_get_access_token() + + async def async_accept_grant(self, code: str) -> str | None: + """Accept a grant.""" + assert self._auth is not None + return await self._auth.async_do_auth(code) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> None: + """Activate Smart Home functionality of Alexa component. + + This is optional, triggered by having a `smart_home:` sub-section in the + alexa configuration. + + Even if that's disabled, the functionality in this module may still be used + by the cloud component which will call async_handle_message directly. + """ + smart_home_config = AlexaConfig(hass, config) + await smart_home_config.async_initialize() + hass.http.register_view(SmartHomeView(smart_home_config)) + + if smart_home_config.should_report_state: + await smart_home_config.async_enable_proactive_mode() + + +class SmartHomeView(HomeAssistantView): + """Expose Smart Home v3 payload interface via HTTP POST.""" + + url = SMART_HOME_HTTP_ENDPOINT + name = "api:alexa:smart_home" + + def __init__(self, smart_home_config: AlexaConfig) -> None: + """Initialize.""" + self.smart_home_config = smart_home_config + + async def post(self, request: HomeAssistantRequest) -> web.Response | bytes: + """Handle Alexa Smart Home requests. + + The Smart Home API requires the endpoint to be implemented in AWS + Lambda, which will need to forward the requests to here and pass back + the response. + """ + hass: HomeAssistant = request.app["hass"] + user: User = request["hass_user"] + message: dict[str, Any] = await request.json() + + _LOGGER.debug("Received Alexa Smart Home request: %s", message) + + response = await async_handle_message( + hass, self.smart_home_config, message, context=core.Context(user_id=user.id) + ) + _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + return b"" if response is None else self.json(response) + + +async def async_handle_message( + hass: HomeAssistant, + config: AbstractConfig, + request: dict[str, Any], + context: Context | None = None, + enabled: bool = True, +) -> dict[str, Any]: """Handle incoming API messages. If enabled is False, the response to all messages will be a @@ -21,7 +174,7 @@ async def async_handle_message(hass, config, request, context=None, enabled=True assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3" if context is None: - context = ha.Context() + context = Context() directive = AlexaDirective(request) @@ -48,7 +201,7 @@ async def async_handle_message(hass, config, request, context=None, enabled=True response = directive.error() except AlexaError as err: response = directive.error( - error_type=err.error_type, + error_type=str(err.error_type), error_message=err.error_message, payload=err.payload, ) @@ -61,9 +214,13 @@ async def async_handle_message(hass, config, request, context=None, enabled=True ) response = directive.error(error_message="Unknown error") - request_info = {"namespace": directive.namespace, "name": directive.name} + request_info: dict[str, Any] = { + "namespace": directive.namespace, + "name": directive.name, + } if directive.has_endpoint: + assert directive.entity_id is not None request_info["entity_id"] = directive.entity_id hass.bus.async_fire( diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py deleted file mode 100644 index 3a702421d94..00000000000 --- a/homeassistant/components/alexa/smart_home_http.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Alexa HTTP interface.""" -import logging - -from homeassistant import core -from homeassistant.components.http.view import HomeAssistantView -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.typing import ConfigType - -from .auth import Auth -from .config import AbstractConfig -from .const import CONF_ENDPOINT, CONF_ENTITY_CONFIG, CONF_FILTER, CONF_LOCALE -from .smart_home import async_handle_message - -_LOGGER = logging.getLogger(__name__) -SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home" - - -class AlexaConfig(AbstractConfig): - """Alexa config.""" - - def __init__(self, hass, config): - """Initialize Alexa config.""" - super().__init__(hass) - self._config = config - - if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): - self._auth = Auth(hass, config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET]) - else: - self._auth = None - - @property - def supports_auth(self): - """Return if config supports auth.""" - return self._auth is not None - - @property - def should_report_state(self): - """Return if we should proactively report states.""" - return self._auth is not None and self.authorized - - @property - def endpoint(self): - """Endpoint for report state.""" - return self._config.get(CONF_ENDPOINT) - - @property - def entity_config(self): - """Return entity config.""" - return self._config.get(CONF_ENTITY_CONFIG) or {} - - @property - def locale(self): - """Return config locale.""" - return self._config.get(CONF_LOCALE) - - @core.callback - def user_identifier(self): - """Return an identifier for the user that represents this config.""" - return "" - - @core.callback - def should_expose(self, entity_id): - """If an entity should be exposed.""" - if not self._config[CONF_FILTER].empty_filter: - return self._config[CONF_FILTER](entity_id) - - entity_registry = er.async_get(self.hass) - if registry_entry := entity_registry.async_get(entity_id): - auxiliary_entity = ( - registry_entry.entity_category is not None - or registry_entry.hidden_by is not None - ) - else: - auxiliary_entity = False - return not auxiliary_entity - - @core.callback - def async_invalidate_access_token(self): - """Invalidate access token.""" - self._auth.async_invalidate_access_token() - - async def async_get_access_token(self): - """Get an access token.""" - return await self._auth.async_get_access_token() - - async def async_accept_grant(self, code): - """Accept a grant.""" - return await self._auth.async_do_auth(code) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> None: - """Activate Smart Home functionality of Alexa component. - - This is optional, triggered by having a `smart_home:` sub-section in the - alexa configuration. - - Even if that's disabled, the functionality in this module may still be used - by the cloud component which will call async_handle_message directly. - """ - smart_home_config = AlexaConfig(hass, config) - await smart_home_config.async_initialize() - hass.http.register_view(SmartHomeView(smart_home_config)) - - if smart_home_config.should_report_state: - await smart_home_config.async_enable_proactive_mode() - - -class SmartHomeView(HomeAssistantView): - """Expose Smart Home v3 payload interface via HTTP POST.""" - - url = SMART_HOME_HTTP_ENDPOINT - name = "api:alexa:smart_home" - - def __init__(self, smart_home_config): - """Initialize.""" - self.smart_home_config = smart_home_config - - async def post(self, request): - """Handle Alexa Smart Home requests. - - The Smart Home API requires the endpoint to be implemented in AWS - Lambda, which will need to forward the requests to here and pass back - the response. - """ - hass = request.app["hass"] - user = request["hass_user"] - message = await request.json() - - _LOGGER.debug("Received Alexa Smart Home request: %s", message) - - response = await async_handle_message( - hass, self.smart_home_config, message, context=core.Context(user_id=user.id) - ) - _LOGGER.debug("Sending Alexa Smart Home response: %s", response) - return b"" if response is None else self.json(response) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 04bb561560f..786b2ee5227 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -2,27 +2,40 @@ from __future__ import annotations import asyncio +from asyncio import timeout from http import HTTPStatus import json import logging -from typing import TYPE_CHECKING, cast +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, cast +from uuid import uuid4 import aiohttp -import async_timeout from homeassistant.components import event from homeassistant.const import MATCH_ALL, STATE_ON -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util from homeassistant.util.json import JsonObjectType, json_loads_object -from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause +from .const import ( + API_CHANGE, + API_CONTEXT, + API_DIRECTIVE, + API_ENDPOINT, + API_EVENT, + API_HEADER, + API_PAYLOAD, + API_SCOPE, + DATE_FORMAT, + DOMAIN, + Cause, +) from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id -from .errors import NoTokenAvailable, RequireRelink -from .messages import AlexaResponse +from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink if TYPE_CHECKING: from .config import AbstractConfig @@ -31,7 +44,202 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 -async def async_enable_proactive_mode(hass, smart_home_config): +class AlexaDirective: + """An incoming Alexa directive.""" + + entity: State + entity_id: str | None + endpoint: AlexaEntity + instance: str | None + + def __init__(self, request: dict[str, Any]) -> None: + """Initialize a directive.""" + self._directive: dict[str, Any] = request[API_DIRECTIVE] + self.namespace: str = self._directive[API_HEADER]["namespace"] + self.name: str = self._directive[API_HEADER]["name"] + self.payload: dict[str, Any] = self._directive[API_PAYLOAD] + self.has_endpoint: bool = API_ENDPOINT in self._directive + self.instance = None + self.entity_id = None + + def load_entity(self, hass: HomeAssistant, config: AbstractConfig) -> None: + """Set attributes related to the entity for this request. + + Sets these attributes when self.has_endpoint is True: + + - entity + - entity_id + - endpoint + - instance (when header includes instance property) + + Behavior when self.has_endpoint is False is undefined. + + Will raise AlexaInvalidEndpointError if the endpoint in the request is + malformed or nonexistent. + """ + _endpoint_id: str = self._directive[API_ENDPOINT]["endpointId"] + self.entity_id = _endpoint_id.replace("#", ".") + + entity: State | None = hass.states.get(self.entity_id) + if not entity or not config.should_expose(self.entity_id): + raise AlexaInvalidEndpointError(_endpoint_id) + self.entity = entity + + self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity) + if "instance" in self._directive[API_HEADER]: + self.instance = self._directive[API_HEADER]["instance"] + + def response( + self, + name: str = "Response", + namespace: str = "Alexa", + payload: dict[str, Any] | None = None, + ) -> AlexaResponse: + """Create an API formatted response. + + Async friendly. + """ + response = AlexaResponse(name, namespace, payload) + + token = self._directive[API_HEADER].get("correlationToken") + if token: + response.set_correlation_token(token) + + if self.has_endpoint: + response.set_endpoint(self._directive[API_ENDPOINT].copy()) + + return response + + def error( + self, + namespace: str = "Alexa", + error_type: str = "INTERNAL_ERROR", + error_message: str = "", + payload: dict[str, Any] | None = None, + ) -> AlexaResponse: + """Create a API formatted error response. + + Async friendly. + """ + payload = payload or {} + payload["type"] = error_type + payload["message"] = error_message + + _LOGGER.info( + "Request %s/%s error %s: %s", + self._directive[API_HEADER]["namespace"], + self._directive[API_HEADER]["name"], + error_type, + error_message, + ) + + return self.response(name="ErrorResponse", namespace=namespace, payload=payload) + + +class AlexaResponse: + """Class to hold a response.""" + + def __init__( + self, name: str, namespace: str, payload: dict[str, Any] | None = None + ) -> None: + """Initialize the response.""" + payload = payload or {} + self._response: dict[str, Any] = { + API_EVENT: { + API_HEADER: { + "namespace": namespace, + "name": name, + "messageId": str(uuid4()), + "payloadVersion": "3", + }, + API_PAYLOAD: payload, + } + } + + @property + def name(self) -> str: + """Return the name of this response.""" + name: str = self._response[API_EVENT][API_HEADER]["name"] + return name + + @property + def namespace(self) -> str: + """Return the namespace of this response.""" + namespace: str = self._response[API_EVENT][API_HEADER]["namespace"] + return namespace + + def set_correlation_token(self, token: str) -> None: + """Set the correlationToken. + + This should normally mirror the value from a request, and is set by + AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_HEADER]["correlationToken"] = token + + def set_endpoint_full( + self, bearer_token: str | None, endpoint_id: str | None + ) -> None: + """Set the endpoint dictionary. + + This is used to send proactive messages to Alexa. + """ + self._response[API_EVENT][API_ENDPOINT] = { + API_SCOPE: {"type": "BearerToken", "token": bearer_token} + } + + if endpoint_id is not None: + self._response[API_EVENT][API_ENDPOINT]["endpointId"] = endpoint_id + + def set_endpoint(self, endpoint: dict[str, Any]) -> None: + """Set the endpoint. + + This should normally mirror the value from a request, and is set by + AlexaDirective.response() usually. + """ + self._response[API_EVENT][API_ENDPOINT] = endpoint + + def _properties(self) -> list[dict[str, Any]]: + context: dict[str, Any] = self._response.setdefault(API_CONTEXT, {}) + properties: list[dict[str, Any]] = context.setdefault("properties", []) + return properties + + def add_context_property(self, prop: dict[str, Any]) -> None: + """Add a property to the response context. + + The Alexa response includes a list of properties which provides + feedback on how states have changed. For example if a user asks, + "Alexa, set thermostat to 20 degrees", the API expects a response with + the new value of the property, and Alexa will respond to the user + "Thermostat set to 20 degrees". + + async_handle_message() will call .merge_context_properties() for every + request automatically, however often handlers will call services to + change state but the effects of those changes are applied + asynchronously. Thus, handlers should call this method to confirm + changes before returning. + """ + self._properties().append(prop) + + def merge_context_properties(self, endpoint: AlexaEntity) -> None: + """Add all properties from given endpoint if not already set. + + Handlers should be using .add_context_property(). + """ + properties = self._properties() + already_set = {(p["namespace"], p["name"]) for p in properties} + + for prop in endpoint.serialize_properties(): + if (prop["namespace"], prop["name"]) not in already_set: + self.add_context_property(prop) + + def serialize(self) -> dict[str, Any]: + """Return response as a JSON-able data structure.""" + return self._response + + +async def async_enable_proactive_mode( + hass: HomeAssistant, smart_home_config: AbstractConfig +) -> CALLBACK_TYPE | None: """Enable the proactive mode. Proactive mode makes this component report state changes to Alexa. @@ -43,12 +251,12 @@ async def async_enable_proactive_mode(hass, smart_home_config): def extra_significant_check( hass: HomeAssistant, old_state: str, - old_attrs: dict, - old_extra_arg: dict, + old_attrs: dict[Any, Any] | MappingProxyType[Any, Any], + old_extra_arg: Any, new_state: str, - new_attrs: dict, - new_extra_arg: dict, - ): + new_attrs: dict[str, Any] | MappingProxyType[Any, Any], + new_extra_arg: Any, + ) -> bool: """Check if the serialized data has changed.""" return old_extra_arg is not None and old_extra_arg != new_extra_arg @@ -58,7 +266,7 @@ async def async_enable_proactive_mode(hass, smart_home_config): changed_entity: str, old_state: State | None, new_state: State | None, - ): + ) -> None: if not hass.is_running: return @@ -117,8 +325,13 @@ async def async_enable_proactive_mode(hass, smart_home_config): async def async_send_changereport_message( - hass, config, alexa_entity, alexa_properties, *, invalidate_access_token=True -): + hass: HomeAssistant, + config: AbstractConfig, + alexa_entity: AlexaEntity, + alexa_properties: list[dict[str, Any]], + *, + invalidate_access_token: bool = True, +) -> None: """Send a ChangeReport message for an Alexa entity. https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events @@ -132,11 +345,11 @@ async def async_send_changereport_message( ) return - headers = {"Authorization": f"Bearer {token}"} + headers: dict[str, Any] = {"Authorization": f"Bearer {token}"} endpoint = alexa_entity.alexa_id() - payload = { + payload: dict[str, Any] = { API_CHANGE: { "cause": {"type": Cause.APP_INTERACTION}, "properties": alexa_properties, @@ -149,8 +362,9 @@ async def async_send_changereport_message( message_serialized = message.serialize() session = async_get_clientsession(hass) + assert config.endpoint is not None try: - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, @@ -203,9 +417,9 @@ async def async_send_add_or_update_message( """ token = await config.async_get_access_token() - headers = {"Authorization": f"Bearer {token}"} + headers: dict[str, Any] = {"Authorization": f"Bearer {token}"} - endpoints = [] + endpoints: list[dict[str, Any]] = [] for entity_id in entity_ids: if (domain := entity_id.split(".", 1)[0]) not in ENTITY_ADAPTERS: @@ -217,7 +431,10 @@ async def async_send_add_or_update_message( alexa_entity = ENTITY_ADAPTERS[domain](hass, config, state) endpoints.append(alexa_entity.serialize_discovery()) - payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} + payload: dict[str, Any] = { + "endpoints": endpoints, + "scope": {"type": "BearerToken", "token": token}, + } message = AlexaResponse( name="AddOrUpdateReport", namespace="Alexa.Discovery", payload=payload @@ -226,6 +443,7 @@ async def async_send_add_or_update_message( message_serialized = message.serialize() session = async_get_clientsession(hass) + assert config.endpoint is not None return await session.post( config.endpoint, headers=headers, json=message_serialized, allow_redirects=True ) @@ -240,9 +458,9 @@ async def async_send_delete_message( """ token = await config.async_get_access_token() - headers = {"Authorization": f"Bearer {token}"} + headers: dict[str, Any] = {"Authorization": f"Bearer {token}"} - endpoints = [] + endpoints: list[dict[str, Any]] = [] for entity_id in entity_ids: domain = entity_id.split(".", 1)[0] @@ -252,7 +470,10 @@ async def async_send_delete_message( endpoints.append({"endpointId": generate_alexa_id(entity_id)}) - payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} + payload: dict[str, Any] = { + "endpoints": endpoints, + "scope": {"type": "BearerToken", "token": token}, + } message = AlexaResponse( name="DeleteReport", namespace="Alexa.Discovery", payload=payload @@ -261,19 +482,22 @@ async def async_send_delete_message( message_serialized = message.serialize() session = async_get_clientsession(hass) + assert config.endpoint is not None return await session.post( config.endpoint, headers=headers, json=message_serialized, allow_redirects=True ) -async def async_send_doorbell_event_message(hass, config, alexa_entity): +async def async_send_doorbell_event_message( + hass: HomeAssistant, config: AbstractConfig, alexa_entity: AlexaEntity +) -> None: """Send a DoorbellPress event message for an Alexa entity. https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-doorbelleventsource.html """ token = await config.async_get_access_token() - headers = {"Authorization": f"Bearer {token}"} + headers: dict[str, Any] = {"Authorization": f"Bearer {token}"} endpoint = alexa_entity.alexa_id() @@ -291,8 +515,9 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): message_serialized = message.serialize() session = async_get_clientsession(hass) + assert config.endpoint is not None try: - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with timeout(DEFAULT_TIMEOUT): response = await session.post( config.endpoint, headers=headers, diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index aeda26c9b23..57971899cc0 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.20.24"] + "requirements": ["boto3==1.28.17"] } diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index cf8b40916f3..2762c3948a7 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -24,7 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 16d790cc09c..0d259cf337a 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -100,7 +100,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: token_info = await oauth.get_access_token(code) except ambiclimate.AmbiclimateOauthError: - _LOGGER.error("Failed to get access token", exc_info=True) + _LOGGER.exception("Failed to get access token") return None store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index f68ae3df114..1718b559fde 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -5,7 +5,6 @@ from typing import Any from aioambient import Websocket from aioambient.errors import WebsocketError -from aioambient.util import get_public_device_id from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,11 +18,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.dispatcher import async_dispatcher_send import homeassistant.helpers.entity_registry as er from .const import ( @@ -148,6 +143,7 @@ class AmbientStation: """Define a handler to fire when the data is received.""" mac = data["macAddress"] + # If data has not changed, don't update: if data == self.stations[mac][ATTR_LAST_DATA]: return @@ -196,71 +192,3 @@ class AmbientStation: async def ws_disconnect(self) -> None: """Disconnect from the websocket.""" await self.websocket.disconnect() - - -class AmbientWeatherEntity(Entity): - """Define a base Ambient PWS entity.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__( - self, - ambient: AmbientStation, - mac_address: str, - station_name: str, - description: EntityDescription, - ) -> None: - """Initialize the entity.""" - self._ambient = ambient - - public_device_id = get_public_device_id(mac_address) - self._attr_device_info = DeviceInfo( - configuration_url=( - f"https://ambientweather.net/dashboard/{public_device_id}" - ), - identifiers={(DOMAIN, mac_address)}, - manufacturer="Ambient Weather", - name=station_name.capitalize(), - ) - - self._attr_unique_id = f"{mac_address}_{description.key}" - self._mac_address = mac_address - self.entity_description = description - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def update() -> None: - """Update the state.""" - if self.entity_description.key == TYPE_SOLARRADIATION_LX: - self._attr_available = ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ - TYPE_SOLARRADIATION - ] - is not None - ) - else: - self._attr_available = ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ - self.entity_description.key - ] - is not None - ) - - self.update_from_latest_data() - self.async_write_ha_state() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, f"ambient_station_data_update_{self._mac_address}", update - ) - ) - - self.update_from_latest_data() - - @callback - def update_from_latest_data(self) -> None: - """Update the entity from the latest data.""" - raise NotImplementedError diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 8876c1a5c62..49ff43bcc7e 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -14,8 +14,8 @@ from homeassistant.const import ATTR_NAME, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AmbientWeatherEntity from .const import ATTR_LAST_DATA, DOMAIN +from .entity import AmbientWeatherEntity TYPE_BATT1 = "batt1" TYPE_BATT10 = "batt10" @@ -80,304 +80,303 @@ class AmbientBinarySensorDescription( BINARY_SENSOR_DESCRIPTIONS = ( AmbientBinarySensorDescription( key=TYPE_BATTOUT, - name="Battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT1, - name="Battery 1", + translation_key="battery_1", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT2, - name="Battery 2", + translation_key="battery_2", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT3, - name="Battery 3", + translation_key="battery_3", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT4, - name="Battery 4", + translation_key="battery_4", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT5, - name="Battery 5", + translation_key="battery_5", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT6, - name="Battery 6", + translation_key="battery_6", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT7, - name="Battery 7", + translation_key="battery_7", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT8, - name="Battery 8", + translation_key="battery_8", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT9, - name="Battery 9", + translation_key="battery_9", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATTIN, - name="Interior battery", + translation_key="interior_battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT10, - name="Battery 10", + translation_key="battery_10", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_LEAK1, - name="Leak detector battery 1", + translation_key="leak_detector_battery_1", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_LEAK2, - name="Leak detector battery 2", + translation_key="leak_detector_battery_2", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_LEAK3, - name="Leak detector battery 3", + translation_key="leak_detector_battery_3", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_LEAK4, - name="Leak detector battery 4", + translation_key="leak_detector_battery_4", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM1, - name="Soil monitor battery 1", + translation_key="soil_monitor_battery_1", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM2, - name="Soil monitor battery 2", + translation_key="soil_monitor_battery_2", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM3, - name="Soil monitor battery 3", + translation_key="soil_monitor_battery_3", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM4, - name="Soil monitor battery 4", + translation_key="soil_monitor_battery_4", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM5, - name="Soil monitor battery 5", + translation_key="soil_monitor_battery_5", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM6, - name="Soil monitor battery 6", + translation_key="soil_monitor_battery_6", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM7, - name="Soil monitor battery 7", + translation_key="soil_monitor_battery_7", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM8, - name="Soil monitor battery 8", + translation_key="soil_monitor_battery_8", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM9, - name="Soil monitor battery 9", + translation_key="soil_monitor_battery_9", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_SM10, - name="Soil monitor battery 10", + translation_key="soil_monitor_battery_10", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_CO2, - name="CO2 battery", + translation_key="co2_battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_BATT_LIGHTNING, - name="Lightning detector battery", + translation_key="lightning_detector_battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_LEAK1, - name="Leak detector 1", + translation_key="leak_detector_1", device_class=BinarySensorDeviceClass.MOISTURE, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_LEAK2, - name="Leak detector 2", + translation_key="leak_detector_2", device_class=BinarySensorDeviceClass.MOISTURE, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_LEAK3, - name="Leak detector 3", + translation_key="leak_detector_3", device_class=BinarySensorDeviceClass.MOISTURE, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_LEAK4, - name="Leak detector 4", + translation_key="leak_detector_4", device_class=BinarySensorDeviceClass.MOISTURE, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_PM25IN_BATT, - name="PM25 indoor battery", + translation_key="pm25_indoor_battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_PM25_BATT, - name="PM25 battery", + translation_key="pm25_battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), AmbientBinarySensorDescription( key=TYPE_RELAY1, - name="Relay 1", + translation_key="relay_1", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY2, - name="Relay 2", + translation_key="relay_2", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY3, - name="Relay 3", + translation_key="relay_3", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY4, - name="Relay 4", + translation_key="relay_4", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY5, - name="Relay 5", + translation_key="relay_5", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY6, - name="Relay 6", + translation_key="relay_6", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY7, - name="Relay 7", + translation_key="relay_7", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY8, - name="Relay 8", + translation_key="relay_8", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY9, - name="Relay 9", + translation_key="relay_9", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, ), AmbientBinarySensorDescription( key=TYPE_RELAY10, - name="Relay 10", + translation_key="relay_10", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, on_state=1, @@ -409,9 +408,6 @@ class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity): @callback def update_from_latest_data(self) -> None: """Fetch new state data for the entity.""" - self._attr_is_on = ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ - self.entity_description.key - ] - == self.entity_description.on_state - ) + description = self.entity_description + last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA] + self._attr_is_on = last_data[description.key] == description.on_state diff --git a/homeassistant/components/ambient_station/entity.py b/homeassistant/components/ambient_station/entity.py new file mode 100644 index 00000000000..277b69e8f68 --- /dev/null +++ b/homeassistant/components/ambient_station/entity.py @@ -0,0 +1,70 @@ +"""Base entity Ambient Weather Station Service.""" +from __future__ import annotations + +from aioambient.util import get_public_device_id + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity, EntityDescription + +from . import AmbientStation +from .const import ATTR_LAST_DATA, DOMAIN, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX + + +class AmbientWeatherEntity(Entity): + """Define a base Ambient PWS entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + ambient: AmbientStation, + mac_address: str, + station_name: str, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + self._ambient = ambient + + public_device_id = get_public_device_id(mac_address) + self._attr_device_info = DeviceInfo( + configuration_url=( + f"https://ambientweather.net/dashboard/{public_device_id}" + ), + identifiers={(DOMAIN, mac_address)}, + manufacturer="Ambient Weather", + name=station_name.capitalize(), + ) + + self._attr_unique_id = f"{mac_address}_{description.key}" + self._mac_address = mac_address + self.entity_description = description + + @callback + def _async_update(self) -> None: + """Update the state.""" + last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA] + key = self.entity_description.key + available_key = TYPE_SOLARRADIATION if key == TYPE_SOLARRADIATION_LX else key + self._attr_available = last_data[available_key] is not None + self.update_from_latest_data() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"ambient_station_data_update_{self._mac_address}", + self._async_update, + ) + ) + + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 0fc6e7643db..4873da566b5 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -28,8 +28,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AmbientStation, AmbientWeatherEntity +from . import AmbientStation from .const import ATTR_LAST_DATA, DOMAIN, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX +from .entity import AmbientWeatherEntity TYPE_24HOURRAININ = "24hourrainin" TYPE_AQI_PM25 = "aqi_pm25" @@ -113,544 +114,536 @@ TYPE_YEARLYRAININ = "yearlyrainin" SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_24HOURRAININ, - name="24 hr rain", + translation_key="24_hour_rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_AQI_PM25, - name="AQI PM2.5", + translation_key="pm25_aqi", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_AQI_PM25_24H, - name="AQI PM2.5 24h avg", + translation_key="pm25_aqi_24h_average", device_class=SensorDeviceClass.AQI, ), SensorEntityDescription( key=TYPE_AQI_PM25_IN, - name="AQI PM2.5 indoor", + translation_key="pm25_indoor_aqi", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_AQI_PM25_IN_24H, - name="AQI PM2.5 indoor 24h avg", + translation_key="pm25_indoor_aqi_24h_average", device_class=SensorDeviceClass.AQI, ), SensorEntityDescription( key=TYPE_BAROMABSIN, - name="Abs pressure", + translation_key="absolute_pressure", native_unit_of_measurement=UnitOfPressure.INHG, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_BAROMRELIN, - name="Rel pressure", + translation_key="relative_pressure", native_unit_of_measurement=UnitOfPressure.INHG, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_CO2, - name="CO2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_DAILYRAININ, - name="Daily rain", + translation_key="daily_rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_DEWPOINT, - name="Dew point", + translation_key="dew_point", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_EVENTRAININ, - name="Event rain", + translation_key="event_rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_FEELSLIKE, - name="Feels like", + translation_key="feels_like", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HOURLYRAININ, - name="Hourly rain rate", native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key=TYPE_HUMIDITY10, - name="Humidity 10", + translation_key="humidity_10", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY1, - name="Humidity 1", + translation_key="humidity_1", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY2, - name="Humidity 2", + translation_key="humidity_2", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY3, - name="Humidity 3", + translation_key="humidity_3", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY4, - name="Humidity 4", + translation_key="humidity_4", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY5, - name="Humidity 5", + translation_key="humidity_5", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY6, - name="Humidity 6", + translation_key="humidity_6", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY7, - name="Humidity 7", + translation_key="humidity_7", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY8, - name="Humidity 8", + translation_key="humidity_8", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY9, - name="Humidity 9", + translation_key="humidity_9", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_HUMIDITYIN, - name="Humidity in", + translation_key="humidity_indoor", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_LASTRAIN, - name="Last rain", + translation_key="last_rain", icon="mdi:water", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key=TYPE_LIGHTNING_PER_DAY, - name="Lightning strikes 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, - name="Lightning strikes per hour", + translation_key="lightning_strikes_per_hour", icon="mdi:lightning-bolt", native_unit_of_measurement="strikes", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_MAXDAILYGUST, - name="Max gust", + translation_key="max_gust", native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_MONTHLYRAININ, - name="Monthly rain", + translation_key="monthly_rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_PM25_24H, - name="PM25 24h avg", + translation_key="pm25_24h_average", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, ), SensorEntityDescription( key=TYPE_PM25_IN, - name="PM25 indoor", + translation_key="pm25_indoor", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_PM25_IN_24H, - name="PM25 indoor 24h avg", + translation_key="pm25_indoor_24h_average", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, ), SensorEntityDescription( key=TYPE_PM25, - name="PM25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM10, - name="Soil humidity 10", + translation_key="soil_humidity_10", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM1, - name="Soil humidity 1", + translation_key="soil_humidity_1", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM2, - name="Soil humidity 2", + translation_key="soil_humidity_2", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM3, - name="Soil humidity 3", + translation_key="soil_humidity_3", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM4, - name="Soil humidity 4", + translation_key="soil_humidity_4", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM5, - name="Soil humidity 5", + translation_key="soil_humidity_5", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM6, - name="Soil humidity 6", + translation_key="soil_humidity_6", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM7, - name="Soil humidity 7", + translation_key="soil_humidity_7", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM8, - name="Soil humidity 8", + translation_key="soil_humidity_8", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILHUM9, - name="Soil humidity 9", + translation_key="soil_humidity_9", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP10F, - name="Soil temp 10", + translation_key="soil_temperature_10", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP1F, - name="Soil temp 1", + translation_key="soil_temperature_1", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP2F, - name="Soil temp 2", + translation_key="soil_temperature_2", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP3F, - name="Soil temp 3", + translation_key="soil_temperature_3", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP4F, - name="Soil temp 4", + translation_key="soil_temperature_4", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP5F, - name="Soil temp 5", + translation_key="soil_temperature_5", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP6F, - name="Soil temp 6", + translation_key="soil_temperature_6", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP7F, - name="Soil temp 7", + translation_key="soil_temperature_7", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP8F, - name="Soil temp 8", + translation_key="soil_temperature_8", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOILTEMP9F, - name="Soil temp 9", + translation_key="soil_temperature_9", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOLARRADIATION, - name="Solar rad", native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, device_class=SensorDeviceClass.IRRADIANCE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SOLARRADIATION_LX, - name="Solar rad", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP10F, - name="Temp 10", + translation_key="temperature_10", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP1F, - name="Temp 1", + translation_key="temperature_1", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP2F, - name="Temp 2", + translation_key="temperature_2", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP3F, - name="Temp 3", + translation_key="temperature_3", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP4F, - name="Temp 4", + translation_key="temperature_4", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP5F, - name="Temp 5", + translation_key="temperature_5", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP6F, - name="Temp 6", + translation_key="temperature_6", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP7F, - name="Temp 7", + translation_key="temperature_7", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP8F, - name="Temp 8", + translation_key="temperature_8", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMP9F, - name="Temp 9", + translation_key="temperature_9", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMPF, - name="Temp", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TEMPINF, - name="Inside temp", + translation_key="inside_temperature", native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_TOTALRAININ, - name="Lifetime rain", + translation_key="lifetime_rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key=TYPE_UV, - name="UV index", + translation_key="uv_index", native_unit_of_measurement="Index", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_WEEKLYRAININ, - name="Weekly rain", + translation_key="weekly_rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key=TYPE_WINDDIR, - name="Wind dir", + translation_key="wind_direction", icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, - name="Wind dir avg 10m", + translation_key="wind_direction_average_10m", icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG2M, - name="Wind dir avg 2m", + translation_key="wind_direction_average_2m", icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDGUSTDIR, - name="Gust dir", + translation_key="wind_gust_direction", icon="mdi:weather-windy", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( key=TYPE_WINDGUSTMPH, - name="Wind gust", + translation_key="wind_gust", native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_WINDSPDMPH_AVG10M, - name="Wind avg 10m", + translation_key="wind_average_10m", native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key=TYPE_WINDSPDMPH_AVG2M, - name="Wind avg 2m", + translation_key="wind_average_2m", native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key=TYPE_WINDSPEEDMPH, - name="Wind speed", native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_YEARLYRAININ, - name="Yearly rain", + translation_key="yearly_rain", native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, @@ -694,11 +687,9 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): @callback def update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - raw = self._ambient.stations[self._mac_address][ATTR_LAST_DATA][ - self.entity_description.key - ] - - if self.entity_description.key == TYPE_LASTRAIN: + key = self.entity_description.key + raw = self._ambient.stations[self._mac_address][ATTR_LAST_DATA][key] + if key == TYPE_LASTRAIN: self._attr_native_value = datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S.%f%z") else: self._attr_native_value = raw diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json index a9bce82e10b..02bceda500f 100644 --- a/homeassistant/components/ambient_station/strings.json +++ b/homeassistant/components/ambient_station/strings.json @@ -16,5 +16,356 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "binary_sensor": { + "battery_1": { + "name": "Battery 1" + }, + "battery_2": { + "name": "Battery 2" + }, + "battery_3": { + "name": "Battery 3" + }, + "battery_4": { + "name": "Battery 4" + }, + "battery_5": { + "name": "Battery 5" + }, + "battery_6": { + "name": "Battery 6" + }, + "battery_7": { + "name": "Battery 7" + }, + "battery_8": { + "name": "Battery 8" + }, + "battery_9": { + "name": "Battery 9" + }, + "battery_10": { + "name": "Battery 10" + }, + "interior_battery": { + "name": "Interior battery" + }, + "leak_detector_battery_1": { + "name": "Leak detector battery 1" + }, + "leak_detector_battery_2": { + "name": "Leak detector battery 2" + }, + "leak_detector_battery_3": { + "name": "Leak detector battery 3" + }, + "leak_detector_battery_4": { + "name": "Leak detector battery 4" + }, + "soil_monitor_battery_1": { + "name": "Soil monitor battery 1" + }, + "soil_monitor_battery_2": { + "name": "Soil monitor battery 2" + }, + "soil_monitor_battery_3": { + "name": "Soil monitor battery 3" + }, + "soil_monitor_battery_4": { + "name": "Soil monitor battery 4" + }, + "soil_monitor_battery_5": { + "name": "Soil monitor battery 5" + }, + "soil_monitor_battery_6": { + "name": "Soil monitor battery 6" + }, + "soil_monitor_battery_7": { + "name": "Soil monitor battery 7" + }, + "soil_monitor_battery_8": { + "name": "Soil monitor battery 8" + }, + "soil_monitor_battery_9": { + "name": "Soil monitor battery 9" + }, + "soil_monitor_battery_10": { + "name": "Soil monitor battery 10" + }, + "co2_battery": { + "name": "Carbon dioxide battery" + }, + "lightning_detector_battery": { + "name": "Lightning detector battery" + }, + "leak_detector_1": { + "name": "Leak detector 1" + }, + "leak_detector_2": { + "name": "Leak detector 2" + }, + "leak_detector_3": { + "name": "Leak detector 3" + }, + "leak_detector_4": { + "name": "Leak detector 4" + }, + "pm25_indoor_battery": { + "name": "PM25 indoor battery" + }, + "pm25_battery": { + "name": "PM25 battery" + }, + "relay_1": { + "name": "Relay 1" + }, + "relay_2": { + "name": "Relay 2" + }, + "relay_3": { + "name": "Relay 3" + }, + "relay_4": { + "name": "Relay 4" + }, + "relay_5": { + "name": "Relay 5" + }, + "relay_6": { + "name": "Relay 6" + }, + "relay_7": { + "name": "Relay 7" + }, + "relay_8": { + "name": "Relay 8" + }, + "relay_9": { + "name": "Relay 9" + }, + "relay_10": { + "name": "Relay 10" + } + }, + "sensor": { + "24_hour_rain": { + "name": "Rain 24 hours" + }, + "pm25_aqi": { + "name": "PM2.5 AQI" + }, + "pm25_aqi_24h_average": { + "name": "PM2.5 AQI 24 hour average" + }, + "pm25_indoor_aqi": { + "name": "PM2.5 indoor AQI" + }, + "pm25_indoor_aqi_24h_average": { + "name": "PM2.5 indoor AQI" + }, + "absolute_pressure": { + "name": "Absolute pressure" + }, + "relative_pressure": { + "name": "Relative pressure" + }, + "daily_rain": { + "name": "Daily rain" + }, + "dew_point": { + "name": "Dew point" + }, + "event_rain": { + "name": "Event rain" + }, + "feels_like": { + "name": "Feels like" + }, + "humidity_1": { + "name": "Humidity 1" + }, + "humidity_2": { + "name": "Humidity 2" + }, + "humidity_3": { + "name": "Humidity 3" + }, + "humidity_4": { + "name": "Humidity 4" + }, + "humidity_5": { + "name": "Humidity 5" + }, + "humidity_6": { + "name": "Humidity 6" + }, + "humidity_7": { + "name": "Humidity 7" + }, + "humidity_8": { + "name": "Humidity 8" + }, + "humidity_9": { + "name": "Humidity 9" + }, + "humidity_10": { + "name": "Humidity 10" + }, + "humidity_indoor": { + "name": "Humidity indoor" + }, + "last_rain": { + "name": "Last rain" + }, + "lightning_strikes_per_day": { + "name": "Lightning strikes per day" + }, + "lightning_strikes_per_hour": { + "name": "Lightning strikes per hour" + }, + "max_gust": { + "name": "Max gust" + }, + "monthly_rain": { + "name": "Monthly rain" + }, + "pm25_24h_average": { + "name": "PM2.5 24 hour average" + }, + "pm25_indoor": { + "name": "PM2.5 indoor" + }, + "pm25_indoor_24h_average": { + "name": "PM2.5 indoor 24 hour average" + }, + "soil_humidity_1": { + "name": "Soil humidity 1" + }, + "soil_humidity_2": { + "name": "Soil humidity 2" + }, + "soil_humidity_3": { + "name": "Soil humidity 3" + }, + "soil_humidity_4": { + "name": "Soil humidity 4" + }, + "soil_humidity_5": { + "name": "Soil humidity 5" + }, + "soil_humidity_6": { + "name": "Soil humidity 6" + }, + "soil_humidity_7": { + "name": "Soil humidity 7" + }, + "soil_humidity_8": { + "name": "Soil humidity 8" + }, + "soil_humidity_9": { + "name": "Soil humidity 9" + }, + "soil_humidity_10": { + "name": "Soil humidity 10" + }, + "soil_temperature_1": { + "name": "Soil temperature 1" + }, + "soil_temperature_2": { + "name": "Soil temperature 2" + }, + "soil_temperature_3": { + "name": "Soil temperature 3" + }, + "soil_temperature_4": { + "name": "Soil temperature 4" + }, + "soil_temperature_5": { + "name": "Soil temperature 5" + }, + "soil_temperature_6": { + "name": "Soil temperature 6" + }, + "soil_temperature_7": { + "name": "Soil temperature 7" + }, + "soil_temperature_8": { + "name": "Soil temperature 8" + }, + "soil_temperature_9": { + "name": "Soil temperature 9" + }, + "soil_temperature_10": { + "name": "Soil temperature 10" + }, + "temperature_1": { + "name": "Temperature 1" + }, + "temperature_2": { + "name": "Temperature 2" + }, + "temperature_3": { + "name": "Temperature 3" + }, + "temperature_4": { + "name": "Temperature 4" + }, + "temperature_5": { + "name": "Temperature 5" + }, + "temperature_6": { + "name": "Temperature 6" + }, + "temperature_7": { + "name": "Temperature 7" + }, + "temperature_8": { + "name": "Temperature 8" + }, + "temperature_9": { + "name": "Temperature 9" + }, + "temperature_10": { + "name": "Temperature 10" + }, + "inside_temperature": { + "name": "Inside temperature" + }, + "lifetime_rain": { + "name": "Lifetime rain" + }, + "uv_index": { + "name": "UV index" + }, + "weekly_rain": { + "name": "Weekly rain" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_direction_average_10m": { + "name": "Wind direction average 10 minutes" + }, + "wind_direction_average_2m": { + "name": "Wind direction average 2 minutes" + }, + "wind_gust_direction": { + "name": "Wind gust direction" + }, + "wind_gust": { + "name": "Wind gust" + }, + "wind_average_10m": { + "name": "Wind average 10 minutes" + }, + "wind_average_2m": { + "name": "Wind average 2 minutes" + }, + "yearly_rain": { + "name": "Yearly rain" + } + } } } diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 19e6b5ec7b3..1c81eacd14a 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -2,13 +2,13 @@ from __future__ import annotations import asyncio +from asyncio import timeout from dataclasses import asdict as dataclass_asdict, dataclass from datetime import datetime from typing import Any import uuid import aiohttp -import async_timeout from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE @@ -22,9 +22,7 @@ from homeassistant.components.recorder import ( get_instance as get_recorder_instance, ) import homeassistant.config as conf_util -from homeassistant.config_entries import ( - SOURCE_IGNORE, -) +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -315,7 +313,7 @@ class Analytics: ) try: - async with async_timeout.timeout(30): + async with timeout(30): response = await self.session.post(self.endpoint, json=payload) if response.status == 200: LOGGER.info( diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py index db6548411a9..92ff29177dd 100644 --- a/homeassistant/components/android_ip_webcam/camera.py +++ b/homeassistant/components/android_ip_webcam/camera.py @@ -11,7 +11,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/android_ip_webcam/entity.py b/homeassistant/components/android_ip_webcam/entity.py index 025132e4bfb..d729da22a9d 100644 --- a/homeassistant/components/android_ip_webcam/entity.py +++ b/homeassistant/components/android_ip_webcam/entity.py @@ -1,7 +1,7 @@ """Base class for Android IP Webcam entities.""" from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 4f927f242df..1fec605d8e1 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -32,9 +32,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 4c58f82b8e7..9471504808c 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from asyncio import timeout import logging from androidtvremote2 import ( @@ -10,7 +11,6 @@ from androidtvremote2 import ( ConnectionClosed, InvalidAuth, ) -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.add_is_available_updated_callback(is_available_updated) try: - async with async_timeout.timeout(5.0): + async with timeout(5.0): await api.async_connect() except InvalidAuth as exc: # The Android TV is hard reset or the certificate and key files were deleted. diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index d5c361674bd..03e09c6ecb0 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -12,8 +12,12 @@ from androidtvremote2 import ( ) import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import zeroconf +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -35,7 +39,7 @@ STEP_PAIR_DATA_SCHEMA = vol.Schema( ) -class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Android TV Remote.""" VERSION = 1 @@ -43,7 +47,7 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new AndroidTVRemoteConfigFlow.""" self.api: AndroidTVRemote | None = None - self.reauth_entry: config_entries.ConfigEntry | None = None + self.reauth_entry: ConfigEntry | None = None self.host: str | None = None self.name: str | None = None self.mac: str | None = None @@ -192,19 +196,15 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: + config_entry: ConfigEntry, + ) -> AndroidTVRemoteOptionsFlowHandler: """Create the options flow.""" - return OptionsFlowHandler(config_entry) + return AndroidTVRemoteOptionsFlowHandler(config_entry) -class OptionsFlowHandler(config_entries.OptionsFlow): +class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry): """Android TV Remote options flow.""" - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index 5a99805da62..86c8d16260c 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -7,8 +7,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 2fee8a6beeb..6181d02025d 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -6,7 +6,7 @@ import logging from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -42,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device[1], api.jwt, ) - for device in entry.data["devices"] + for device in entry.data[CONF_DEVICES] ] try: new_devices = await api.get_devices() @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={ **entry.data, - **{"devices": serialize_device_list(devices)}, + **{CONF_DEVICES: serialize_device_list(devices)}, }, ) coordinators = [AnovaCoordinator(hass, device) for device in devices] diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py index 5d0d2dbf628..d0846fbffc7 100644 --- a/homeassistant/components/anova/config_flow.py +++ b/homeassistant/components/anova/config_flow.py @@ -4,16 +4,16 @@ from __future__ import annotations from anova_wifi import AnovaApi, InvalidLogin, NoDevicesFound import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_DEVICES, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .util import serialize_device_list -class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AnovaConfligFlow(ConfigFlow, domain=DOMAIN): """Sets up a config flow for Anova.""" VERSION = 1 @@ -25,7 +25,7 @@ class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: api = AnovaApi( - aiohttp_client.async_get_clientsession(self.hass), + async_get_clientsession(self.hass), user_input[CONF_USERNAME], user_input[CONF_PASSWORD], ) @@ -48,7 +48,7 @@ class AnovaConfligFlow(config_entries.ConfigFlow, domain=DOMAIN): data={ CONF_USERNAME: api.username, CONF_PASSWORD: api.password, - "devices": device_list, + CONF_DEVICES: device_list, }, ) diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index 2e5505a9fdd..83dc2c295c3 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -1,12 +1,12 @@ """Support for Anova Coordinators.""" +from asyncio import timeout from datetime import timedelta import logging from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate -import async_timeout from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -30,7 +30,7 @@ class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): update_interval=timedelta(seconds=30), ) assert self.config_entry is not None - self._device_unique_id = anova_device.device_key + self.device_unique_id = anova_device.device_key self.anova_device = anova_device self.device_info: DeviceInfo | None = None @@ -38,7 +38,7 @@ class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): def async_setup(self, firmware_version: str) -> None: """Set the firmware version info.""" self.device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_unique_id)}, + identifiers={(DOMAIN, self.device_unique_id)}, name="Anova Precision Cooker", manufacturer="Anova", model="Precision Cooker", @@ -47,7 +47,7 @@ class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): async def _async_update_data(self) -> APCUpdate: try: - async with async_timeout.timeout(5): + async with timeout(5): return await self.anova_device.update() except AnovaOffline as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/anova/entity.py b/homeassistant/components/anova/entity.py index fd104e194f1..d3ed2eb2667 100644 --- a/homeassistant/components/anova/entity.py +++ b/homeassistant/components/anova/entity.py @@ -8,18 +8,19 @@ from .coordinator import AnovaCoordinator class AnovaEntity(CoordinatorEntity[AnovaCoordinator], Entity): - """Defines a Anova entity.""" + """Defines an Anova entity.""" + + _attr_has_entity_name = True def __init__(self, coordinator: AnovaCoordinator) -> None: """Initialize the Anova entity.""" super().__init__(coordinator) self.device = coordinator.anova_device self._attr_device_info = coordinator.device_info - self._attr_has_entity_name = True -class AnovaDescriptionEntity(AnovaEntity, Entity): - """Defines a Anova entity that uses a description.""" +class AnovaDescriptionEntity(AnovaEntity): + """Defines an Anova entity that uses a description.""" def __init__( self, coordinator: AnovaCoordinator, description: EntityDescription @@ -27,4 +28,4 @@ class AnovaDescriptionEntity(AnovaEntity, Entity): """Initialize the entity and declare unique id based on description key.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator._device_unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.device_unique_id}_{description.key}" diff --git a/homeassistant/components/anthemav/__init__.py b/homeassistant/components/anthemav/__init__.py index fe7fe072785..0a7e36d8a95 100644 --- a/homeassistant/components/anthemav/__init__.py +++ b/homeassistant/components/anthemav/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ANTHEMAV_UDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS, DOMAIN +from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS, DOMAIN PLATFORMS = [Platform.MEDIA_PLAYER] @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def async_anthemav_update_callback(message: str) -> None: """Receive notification from transport that new data exists.""" _LOGGER.debug("Received update callback from AVR: %s", message) - async_dispatcher_send(hass, f"{ANTHEMAV_UDATE_SIGNAL}_{entry.entry_id}") + async_dispatcher_send(hass, f"{ANTHEMAV_UPDATE_SIGNAL}_{entry.entry_id}") try: avr = await anthemav.Connection.create( diff --git a/homeassistant/components/anthemav/const.py b/homeassistant/components/anthemav/const.py index 02f56aed5c4..2b1ff753fba 100644 --- a/homeassistant/components/anthemav/const.py +++ b/homeassistant/components/anthemav/const.py @@ -1,5 +1,5 @@ """Constants for the Anthem A/V Receivers integration.""" -ANTHEMAV_UDATE_SIGNAL = "anthemav_update" +ANTHEMAV_UPDATE_SIGNAL = "anthemav_update" CONF_MODEL = "model" DEFAULT_NAME = "Anthem AV" DEFAULT_PORT = 14999 diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 038e71750dd..4056a34995a 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -15,11 +15,11 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ANTHEMAV_UDATE_SIGNAL, CONF_MODEL, DOMAIN, MANUFACTURER +from .const import ANTHEMAV_UPDATE_SIGNAL, CONF_MODEL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -96,7 +96,7 @@ class AnthemAVR(MediaPlayerEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{ANTHEMAV_UDATE_SIGNAL}_{self._entry_id}", + f"{ANTHEMAV_UPDATE_SIGNAL}_{self._entry_id}", self.update_states, ) ) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index bfe6fe6c80c..164a908e834 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index b465a6b7037..7b13833ccab 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,17 +1,17 @@ """Rest API for Home Assistant.""" import asyncio +from asyncio import timeout from functools import lru_cache from http import HTTPStatus import logging from aiohttp import web from aiohttp.web_exceptions import HTTPBadRequest -import async_timeout import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, MATCH_ALL, @@ -110,10 +110,9 @@ class APIEventStream(HomeAssistantView): url = URL_API_STREAM name = "api:stream" + @require_admin async def get(self, request): """Provide a streaming interface for the event bus.""" - if not request["hass_user"].is_admin: - raise Unauthorized() hass = request.app["hass"] stop_obj = object() to_write = asyncio.Queue() @@ -149,7 +148,7 @@ class APIEventStream(HomeAssistantView): while True: try: - async with async_timeout.timeout(STREAM_PING_INTERVAL): + async with timeout(STREAM_PING_INTERVAL): payload = await to_write.get() if payload is stop_obj: @@ -278,10 +277,9 @@ class APIEventView(HomeAssistantView): url = "/api/events/{event_type}" name = "api:event" + @require_admin async def post(self, request, event_type): """Fire events.""" - if not request["hass_user"].is_admin: - raise Unauthorized() body = await request.text() try: event_data = json_loads(body) if body else None @@ -385,10 +383,9 @@ class APITemplateView(HomeAssistantView): url = URL_API_TEMPLATE name = "api:template" + @require_admin async def post(self, request): """Render a template.""" - if not request["hass_user"].is_admin: - raise Unauthorized() try: data = await request.json() tpl = _cached_template(data["template"], request.app["hass"]) @@ -405,10 +402,9 @@ class APIErrorLog(HomeAssistantView): url = URL_API_ERROR_LOG name = "api:error_log" + @require_admin async def get(self, request): """Retrieve API error log.""" - if not request["hass_user"].is_admin: - raise Unauthorized() return web.FileResponse(request.app["hass"].data[DATA_LOGGING]) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index c1d35c94b4f..818d27bcf77 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -26,11 +26,12 @@ from homeassistant.core import 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.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index 5c223940915..b47af54a51f 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index 9c77690ac22..d9ab17dba86 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -1,11 +1,11 @@ """Arcam component.""" import asyncio +from asyncio import timeout import logging from typing import Any from arcam.fmj import ConnectionFailed from arcam.fmj.client import Client -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform @@ -66,7 +66,7 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N while True: try: - async with async_timeout.timeout(interval): + async with timeout(interval): await client.start() _LOGGER.debug("Client connected %s", client.host) diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index ef83217ee26..174ffda9622 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -3,9 +3,7 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import ( - DEVICE_TRIGGER_BASE_SCHEMA, -) +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 08a65b71193..12114ec04b8 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -1,10 +1,11 @@ """Arcam media player.""" from __future__ import annotations +import functools import logging from typing import Any -from arcam.fmj import SourceCodes +from arcam.fmj import ConnectionFailed, SourceCodes from arcam.fmj.state import State from homeassistant.components.media_player import ( @@ -19,8 +20,9 @@ from homeassistant.components.media_player.errors import BrowseError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .config_flow import get_entry_client @@ -57,6 +59,21 @@ async def async_setup_entry( ) +def convert_exception(func): + """Return decorator to convert a connection error into a home assistant error.""" + + @functools.wraps(func) + async def _convert_exception(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ConnectionFailed as exception: + raise HomeAssistantError( + f"Connection failed to device during {func}" + ) from exception + + return _convert_exception + + class ArcamFmj(MediaPlayerEntity): """Representation of a media device.""" @@ -105,7 +122,10 @@ class ArcamFmj(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Once registered, add listener for events.""" await self._state.start() - await self._state.update() + try: + await self._state.update() + except ConnectionFailed as connection: + _LOGGER.debug("Connection lost during addition: %s", connection) @callback def _data(host: str) -> None: @@ -137,13 +157,18 @@ class ArcamFmj(MediaPlayerEntity): async def async_update(self) -> None: """Force update of state.""" _LOGGER.debug("Update state %s", self.name) - await self._state.update() + try: + await self._state.update() + except ConnectionFailed as connection: + _LOGGER.debug("Connection lost during update: %s", connection) + @convert_exception async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" await self._state.set_mute(mute) self.async_write_ha_state() + @convert_exception async def async_select_source(self, source: str) -> None: """Select a specific source.""" try: @@ -155,31 +180,37 @@ class ArcamFmj(MediaPlayerEntity): await self._state.set_source(value) self.async_write_ha_state() + @convert_exception async def async_select_sound_mode(self, sound_mode: str) -> None: """Select a specific source.""" try: await self._state.set_decode_mode(sound_mode) - except (KeyError, ValueError): - _LOGGER.error("Unsupported sound_mode %s", sound_mode) - return + except (KeyError, ValueError) as exception: + raise HomeAssistantError( + f"Unsupported sound_mode {sound_mode}" + ) from exception self.async_write_ha_state() + @convert_exception async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._state.set_volume(round(volume * 99.0)) self.async_write_ha_state() + @convert_exception async def async_volume_up(self) -> None: """Turn volume up for media player.""" await self._state.inc_volume() self.async_write_ha_state() + @convert_exception async def async_volume_down(self) -> None: """Turn volume up for media player.""" await self._state.dec_volume() self.async_write_ha_state() + @convert_exception async def async_turn_on(self) -> None: """Turn the media player on.""" if self._state.get_power() is not None: @@ -189,6 +220,7 @@ class ArcamFmj(MediaPlayerEntity): _LOGGER.debug("Firing event to turn on device") self.hass.bus.async_fire(EVENT_TURN_ON, {ATTR_ENTITY_ID: self.entity_id}) + @convert_exception async def async_turn_off(self) -> None: """Turn the media player off.""" await self._state.set_power(False) @@ -230,6 +262,7 @@ class ArcamFmj(MediaPlayerEntity): return root + @convert_exception async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 8178e243279..3e0e57fffac 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -37,19 +37,18 @@ class AsekoBinarySensorEntityDescription( UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( AsekoBinarySensorEntityDescription( key="water_flow", - name="Water Flow", + translation_key="water_flow", icon="mdi:waves-arrow-right", value_fn=lambda unit: unit.water_flow, ), AsekoBinarySensorEntityDescription( key="has_alarm", - name="Alarm", + translation_key="alarm", value_fn=lambda unit: unit.has_alarm, device_class=BinarySensorDeviceClass.SAFETY, ), AsekoBinarySensorEntityDescription( key="has_error", - name="Error", value_fn=lambda unit: unit.has_error, device_class=BinarySensorDeviceClass.PROBLEM, ), diff --git a/homeassistant/components/aseko_pool_live/entity.py b/homeassistant/components/aseko_pool_live/entity.py index 9cc402e014c..1defbe18345 100644 --- a/homeassistant/components/aseko_pool_live/entity.py +++ b/homeassistant/components/aseko_pool_live/entity.py @@ -1,7 +1,7 @@ """Aseko entity.""" from aioaseko import Unit -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -11,6 +11,8 @@ from .coordinator import AsekoDataUpdateCoordinator class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]): """Representation of an aseko entity.""" + _attr_has_entity_name = True + def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None: """Initialize the aseko entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index 09c4af31428..d7e5e330705 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -45,13 +45,16 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): super().__init__(unit, coordinator) self._variable = variable - variable_name = { - "Air temp.": "Air Temperature", - "Cl free": "Free Chlorine", - "Water temp.": "Water Temperature", - }.get(self._variable.name, self._variable.name) + translation_key = { + "Air temp.": "air_temperature", + "Cl free": "free_chlorine", + "Water temp.": "water_temperature", + }.get(self._variable.name) + if translation_key is not None: + self._attr_translation_key = translation_key + else: + self._attr_name = self._variable.name - self._attr_name = f"{self._device_name} {variable_name}" self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}" self._attr_native_unit_of_measurement = self._variable.unit diff --git a/homeassistant/components/aseko_pool_live/strings.json b/homeassistant/components/aseko_pool_live/strings.json index 7a91b2c9f8b..2a6df30b148 100644 --- a/homeassistant/components/aseko_pool_live/strings.json +++ b/homeassistant/components/aseko_pool_live/strings.json @@ -16,5 +16,26 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "entity": { + "binary_sensor": { + "water_flow": { + "name": "Water flow" + }, + "alarm": { + "name": "Alarm" + } + }, + "sensor": { + "air_temperature": { + "name": "Air temperature" + }, + "free_chlorine": { + "name": "Free chlorine" + }, + "water_temperature": { + "name": "Water temperature" + } + } } } diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 55b192a730a..7f87bd254d0 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -3,12 +3,13 @@ from __future__ import annotations from collections.abc import AsyncIterable +import voluptuous as vol + from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_CONFIG, DOMAIN from .error import PipelineNotFound from .pipeline import ( Pipeline, @@ -18,6 +19,7 @@ from .pipeline import ( PipelineInput, PipelineRun, PipelineStage, + WakeWordSettings, async_create_default_pipeline, async_get_pipeline, async_get_pipelines, @@ -35,13 +37,23 @@ __all__ = ( "PipelineEvent", "PipelineEventType", "PipelineNotFound", + "WakeWordSettings", ) -CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Optional("debug_recording_dir"): str}, + ) + }, + extra=vol.ALLOW_EXTRA, +) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Assist pipeline integration.""" + hass.data[DATA_CONFIG] = config.get(DOMAIN, {}) + await async_setup_pipeline_store(hass) async_register_websocket_api(hass) @@ -50,6 +62,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_pipeline_from_audio_stream( hass: HomeAssistant, + *, context: Context, event_callback: PipelineEventCallback, stt_metadata: stt.SpeechMetadata, @@ -57,7 +70,10 @@ async def async_pipeline_from_audio_stream( pipeline_id: str | None = None, conversation_id: str | None = None, tts_audio_output: str | None = None, + wake_word_settings: WakeWordSettings | None = None, device_id: str | None = None, + start_stage: PipelineStage = PipelineStage.STT, + end_stage: PipelineStage = PipelineStage.TTS, ) -> None: """Create an audio pipeline from an audio stream. @@ -72,10 +88,11 @@ async def async_pipeline_from_audio_stream( hass, context=context, pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), - start_stage=PipelineStage.STT, - end_stage=PipelineStage.TTS, + start_stage=start_stage, + end_stage=end_stage, event_callback=event_callback, tts_audio_output=tts_audio_output, + wake_word_settings=wake_word_settings, ), ) await pipeline_input.validate() diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 5cbdd5d6350..e21d9003a69 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -1,2 +1,4 @@ """Constants for the Assist pipeline integration.""" DOMAIN = "assist_pipeline" + +DATA_CONFIG = f"{DOMAIN}.config" diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index c5ffdcaf2d3..094913424b6 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -18,6 +18,14 @@ class PipelineNotFound(PipelineError): """Unspecified pipeline picked.""" +class WakeWordDetectionError(PipelineError): + """Error in wake-word-detection portion of pipeline.""" + + +class WakeWordTimeoutError(WakeWordDetectionError): + """Timeout when wake word was not detected.""" + + class SpeechToTextError(PipelineError): """Error in speech-to-text portion of pipeline.""" diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index e97ceae5dec..1db415b29d2 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -2,7 +2,7 @@ "domain": "assist_pipeline", "name": "Assist pipeline", "codeowners": ["@balloob", "@synesthesiam"], - "dependencies": ["conversation", "stt", "tts"], + "dependencies": ["conversation", "stt", "tts", "wake_word"], "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 1be9ddbb14f..520daa9f5c2 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -2,15 +2,27 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable, Callable, Iterable +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum import logging +from pathlib import Path +from queue import Queue +from threading import Thread +import time from typing import Any, cast +import wave import voluptuous as vol -from homeassistant.components import conversation, media_source, stt, tts, websocket_api +from homeassistant.components import ( + conversation, + media_source, + stt, + tts, + wake_word, + websocket_api, +) from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -32,14 +44,18 @@ from homeassistant.util import ( ) from homeassistant.util.limited_size_dict import LimitedSizeDict -from .const import DOMAIN +from .const import DATA_CONFIG, DOMAIN from .error import ( IntentRecognitionError, PipelineError, PipelineNotFound, SpeechToTextError, TextToSpeechError, + WakeWordDetectionError, + WakeWordTimeoutError, ) +from .ring_buffer import RingBuffer +from .vad import VoiceActivityTimeout, VoiceCommandSegmenter _LOGGER = logging.getLogger(__name__) @@ -241,7 +257,11 @@ class PipelineEventType(StrEnum): RUN_START = "run-start" RUN_END = "run-end" + WAKE_WORD_START = "wake_word-start" + WAKE_WORD_END = "wake_word-end" STT_START = "stt-start" + STT_VAD_START = "stt-vad-start" + STT_VAD_END = "stt-vad-end" STT_END = "stt-end" INTENT_START = "intent-start" INTENT_END = "intent-end" @@ -297,12 +317,14 @@ class Pipeline: class PipelineStage(StrEnum): """Stages of a pipeline.""" + WAKE_WORD = "wake_word" STT = "stt" INTENT = "intent" TTS = "tts" PIPELINE_STAGE_ORDER = [ + PipelineStage.WAKE_WORD, PipelineStage.STT, PipelineStage.INTENT, PipelineStage.TTS, @@ -327,6 +349,17 @@ class InvalidPipelineStagesError(PipelineRunValidationError): ) +@dataclass(frozen=True) +class WakeWordSettings: + """Settings for wake word detection.""" + + timeout: float | None = None + """Seconds of silence before detection times out.""" + + audio_seconds_to_buffer: float = 0 + """Seconds of audio to buffer before detection and forward to STT.""" + + @dataclass class PipelineRun: """Running context for a pipeline.""" @@ -341,17 +374,26 @@ class PipelineRun: runner_data: Any | None = None intent_agent: str | None = None tts_audio_output: str | None = None + wake_word_settings: WakeWordSettings | None = None id: str = field(default_factory=ulid_util.ulid) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False) tts_engine: str = field(init=False) tts_options: dict | None = field(init=False, default=None) + wake_word_engine: str = field(init=False) + wake_word_provider: wake_word.WakeWordDetectionEntity = field(init=False) + + debug_recording_thread: Thread | None = None + """Thread that records audio to debug_recording_dir""" + + debug_recording_queue: Queue[str | bytes | None] | None = None + """Queue to communicate with debug recording thread""" def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language - # stt -> intent -> tts + # wake -> stt -> intent -> tts if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( self.start_stage ): @@ -374,8 +416,10 @@ class PipelineRun: return pipeline_data.pipeline_runs[self.pipeline.id][self.id].events.append(event) - def start(self) -> None: + def start(self, device_id: str | None) -> None: """Emit run start event.""" + self._start_debug_recording_thread(device_id) + data = { "pipeline": self.pipeline.id, "language": self.language, @@ -385,14 +429,176 @@ class PipelineRun: self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) - def end(self) -> None: + async def end(self) -> None: """Emit run end event.""" + # Stop the recording thread before emitting run-end. + # This ensures that files are properly closed if the event handler reads them. + await self._stop_debug_recording_thread() + self.process_event( PipelineEvent( PipelineEventType.RUN_END, ) ) + async def prepare_wake_word_detection(self) -> None: + """Prepare wake-word-detection.""" + engine = wake_word.async_default_engine(self.hass) + if engine is None: + raise WakeWordDetectionError( + code="wake-engine-missing", + message="No wake word engine", + ) + + wake_word_provider = wake_word.async_get_wake_word_detection_entity( + self.hass, engine + ) + if wake_word_provider is None: + raise WakeWordDetectionError( + code="wake-provider-missing", + message=f"No wake-word-detection provider for: {engine}", + ) + + self.wake_word_engine = engine + self.wake_word_provider = wake_word_provider + + async def wake_word_detection( + self, + stream: AsyncIterable[bytes], + audio_chunks_for_stt: list[bytes], + ) -> wake_word.DetectionResult | None: + """Run wake-word-detection portion of pipeline. Returns detection result.""" + metadata_dict = asdict( + stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + ) + + # Remove language since it doesn't apply to wake words yet + metadata_dict.pop("language", None) + + self.process_event( + PipelineEvent( + PipelineEventType.WAKE_WORD_START, + { + "engine": self.wake_word_engine, + "metadata": metadata_dict, + }, + ) + ) + + if self.debug_recording_queue is not None: + self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_engine}") + + wake_word_settings = self.wake_word_settings or WakeWordSettings() + + wake_word_vad: VoiceActivityTimeout | None = None + if (wake_word_settings.timeout is not None) and ( + wake_word_settings.timeout > 0 + ): + # Use VAD to determine timeout + wake_word_vad = VoiceActivityTimeout(wake_word_settings.timeout) + + # Audio chunk buffer. This audio will be forwarded to speech-to-text + # after wake-word-detection. + num_audio_bytes_to_buffer = int( + wake_word_settings.audio_seconds_to_buffer * 16000 * 2 # 16-bit @ 16Khz + ) + stt_audio_buffer: RingBuffer | None = None + if num_audio_bytes_to_buffer > 0: + stt_audio_buffer = RingBuffer(num_audio_bytes_to_buffer) + + try: + # Detect wake word(s) + result = await self.wake_word_provider.async_process_audio_stream( + self._wake_word_audio_stream( + audio_stream=stream, + stt_audio_buffer=stt_audio_buffer, + wake_word_vad=wake_word_vad, + ) + ) + + if stt_audio_buffer is not None: + # All audio kept from right before the wake word was detected as + # a single chunk. + audio_chunks_for_stt.append(stt_audio_buffer.getvalue()) + except WakeWordTimeoutError: + _LOGGER.debug("Timeout during wake word detection") + raise + except Exception as src_error: + _LOGGER.exception("Unexpected error during wake-word-detection") + raise WakeWordDetectionError( + code="wake-stream-failed", + message="Unexpected error during wake-word-detection", + ) from src_error + + _LOGGER.debug("wake-word-detection result %s", result) + + if result is None: + wake_word_output: dict[str, Any] = {} + else: + if result.queued_audio: + # Add audio that was pending at detection. + # + # Because detection occurs *after* the wake word was actually + # spoken, we need to make sure pending audio is forwarded to + # speech-to-text so the user does not have to pause before + # speaking the voice command. + for chunk_ts in result.queued_audio: + audio_chunks_for_stt.append(chunk_ts[0]) + + wake_word_output = asdict(result) + + # Remove non-JSON fields + wake_word_output.pop("queued_audio", None) + + self.process_event( + PipelineEvent( + PipelineEventType.WAKE_WORD_END, + {"wake_word_output": wake_word_output}, + ) + ) + + return result + + async def _wake_word_audio_stream( + self, + audio_stream: AsyncIterable[bytes], + stt_audio_buffer: RingBuffer | None, + wake_word_vad: VoiceActivityTimeout | None, + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncIterable[tuple[bytes, int]]: + """Yield audio chunks with timestamps (milliseconds since start of stream). + + Adds audio to a ring buffer that will be forwarded to speech-to-text after + detection. Times out if VAD detects enough silence. + """ + ms_per_sample = sample_rate // 1000 + timestamp_ms = 0 + async for chunk in audio_stream: + if self.debug_recording_queue is not None: + self.debug_recording_queue.put_nowait(chunk) + + yield chunk, timestamp_ms + timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + + # Wake-word-detection occurs *after* the wake word was actually + # spoken. Keeping audio right before detection allows the voice + # command to be spoken immediately after the wake word. + if stt_audio_buffer is not None: + stt_audio_buffer.put(chunk) + + if (wake_word_vad is not None) and (not wake_word_vad.process(chunk)): + raise WakeWordTimeoutError( + code="wake-word-timeout", message="Wake word was not detected" + ) + async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: """Prepare speech-to-text.""" # pipeline.stt_engine can't be None or this function is not called @@ -442,10 +648,17 @@ class PipelineRun: ) ) + if self.debug_recording_queue is not None: + # New recording + self.debug_recording_queue.put_nowait(f"01_stt-{engine}") + try: # Transcribe audio stream result = await self.stt_provider.async_process_audio_stream( - metadata, stream + metadata, + self._speech_to_text_stream( + audio_stream=stream, stt_vad=VoiceCommandSegmenter() + ), ) except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") @@ -480,6 +693,45 @@ class PipelineRun: return result.text + async def _speech_to_text_stream( + self, + audio_stream: AsyncIterable[bytes], + stt_vad: VoiceCommandSegmenter | None, + sample_rate: int = 16000, + sample_width: int = 2, + ) -> AsyncGenerator[bytes, None]: + """Yield audio chunks until VAD detects silence or speech-to-text completes.""" + ms_per_sample = sample_rate // 1000 + sent_vad_start = False + timestamp_ms = 0 + async for chunk in audio_stream: + if self.debug_recording_queue is not None: + self.debug_recording_queue.put_nowait(chunk) + + if stt_vad is not None: + if not stt_vad.process(chunk): + # Silence detected at the end of voice command + self.process_event( + PipelineEvent( + PipelineEventType.STT_VAD_END, + {"timestamp": timestamp_ms}, + ) + ) + break + + if stt_vad.in_command and (not sent_vad_start): + # Speech detected at start of voice command + self.process_event( + PipelineEvent( + PipelineEventType.STT_VAD_START, + {"timestamp": timestamp_ms}, + ) + ) + sent_vad_start = True + + yield chunk + timestamp_ms += (len(chunk) // sample_width) // ms_per_sample + async def prepare_recognize_intent(self) -> None: """Prepare recognizing an intent.""" agent_info = conversation.async_get_agent_info( @@ -637,6 +889,96 @@ class PipelineRun: return tts_media.url + def _start_debug_recording_thread(self, device_id: str | None) -> None: + """Start thread to record wake/stt audio if debug_recording_dir is set.""" + if self.debug_recording_thread is not None: + # Already started + return + + # Directory to save audio for each pipeline run. + # Configured in YAML for assist_pipeline. + if debug_recording_dir := self.hass.data[DATA_CONFIG].get( + "debug_recording_dir" + ): + if device_id is None: + # // + run_recording_dir = ( + Path(debug_recording_dir) + / self.pipeline.name + / str(time.monotonic_ns()) + ) + else: + # /// + run_recording_dir = ( + Path(debug_recording_dir) + / device_id + / self.pipeline.name + / str(time.monotonic_ns()) + ) + + self.debug_recording_queue = Queue() + self.debug_recording_thread = Thread( + target=_pipeline_debug_recording_thread_proc, + args=(run_recording_dir, self.debug_recording_queue), + daemon=True, + ) + self.debug_recording_thread.start() + + async def _stop_debug_recording_thread(self) -> None: + """Stop recording thread.""" + if (self.debug_recording_thread is None) or ( + self.debug_recording_queue is None + ): + # Not running + return + + # Signal thread to stop gracefully + self.debug_recording_queue.put(None) + + # Wait until the thread has finished to ensure that files are fully written + await self.hass.async_add_executor_job(self.debug_recording_thread.join) + + self.debug_recording_queue = None + self.debug_recording_thread = None + + +def _pipeline_debug_recording_thread_proc( + run_recording_dir: Path, + queue: Queue[str | bytes | None], + message_timeout: float = 5, +) -> None: + wav_writer: wave.Wave_write | None = None + + try: + _LOGGER.debug("Saving wake/stt audio to %s", run_recording_dir) + run_recording_dir.mkdir(parents=True, exist_ok=True) + + while True: + message = queue.get(timeout=message_timeout) + if message is None: + # Stop signal + break + + if isinstance(message, str): + # New WAV file name + if wav_writer is not None: + wav_writer.close() + + wav_path = run_recording_dir / f"{message}.wav" + wav_writer = wave.open(str(wav_path), "wb") + wav_writer.setframerate(16000) + wav_writer.setsampwidth(2) + wav_writer.setnchannels(1) + elif isinstance(message, bytes): + # Chunk of 16-bit mono audio at 16Khz + if wav_writer is not None: + wav_writer.writeframes(message) + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception("Unexpected error in debug recording thread") + finally: + if wav_writer is not None: + wav_writer.close() + @dataclass class PipelineInput: @@ -662,18 +1004,50 @@ class PipelineInput: async def execute(self) -> None: """Run pipeline.""" - self.run.start() - current_stage = self.run.start_stage + self.run.start(device_id=self.device_id) + current_stage: PipelineStage | None = self.run.start_stage + stt_audio_buffer: list[bytes] = [] try: + if current_stage == PipelineStage.WAKE_WORD: + # wake-word-detection + assert self.stt_stream is not None + detect_result = await self.run.wake_word_detection( + self.stt_stream, stt_audio_buffer + ) + if detect_result is None: + # No wake word. Abort the rest of the pipeline. + await self.run.end() + return + + current_stage = PipelineStage.STT + # speech-to-text intent_input = self.intent_input if current_stage == PipelineStage.STT: assert self.stt_metadata is not None assert self.stt_stream is not None + + stt_stream = self.stt_stream + + if stt_audio_buffer: + # Send audio in the buffer first to speech-to-text, then move on to stt_stream. + # This is basically an async itertools.chain. + async def buffer_then_audio_stream() -> AsyncGenerator[bytes, None]: + # Buffered audio + for chunk in stt_audio_buffer: + yield chunk + + # Streamed audio + assert self.stt_stream is not None + async for chunk in self.stt_stream: + yield chunk + + stt_stream = buffer_then_audio_stream() + intent_input = await self.run.speech_to_text( self.stt_metadata, - self.stt_stream, + stt_stream, ) current_stage = PipelineStage.INTENT @@ -681,6 +1055,7 @@ class PipelineInput: tts_input = self.tts_input if current_stage == PipelineStage.INTENT: + # intent-recognition assert intent_input is not None tts_input = await self.run.recognize_intent( intent_input, @@ -690,6 +1065,7 @@ class PipelineInput: current_stage = PipelineStage.TTS if self.run.end_stage != PipelineStage.INTENT: + # text-to-speech if current_stage == PipelineStage.TTS: assert tts_input is not None await self.run.text_to_speech(tts_input) @@ -701,13 +1077,14 @@ class PipelineInput: {"code": err.code, "message": err.message}, ) ) - return - - self.run.end() + finally: + # Always end the run since it needs to shut down the debug recording + # thread, etc. + await self.run.end() async def validate(self) -> None: """Validate pipeline input against start stage.""" - if self.run.start_stage == PipelineStage.STT: + if self.run.start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT): if self.run.pipeline.stt_engine is None: raise PipelineRunValidationError( "the pipeline does not support speech-to-text" @@ -741,6 +1118,13 @@ class PipelineInput: prepare_tasks = [] + if ( + start_stage_index + <= PIPELINE_STAGE_ORDER.index(PipelineStage.WAKE_WORD) + <= end_stage_index + ): + prepare_tasks.append(self.run.prepare_wake_word_detection()) + if ( start_stage_index <= PIPELINE_STAGE_ORDER.index(PipelineStage.STT) diff --git a/homeassistant/components/assist_pipeline/ring_buffer.py b/homeassistant/components/assist_pipeline/ring_buffer.py new file mode 100644 index 00000000000..d134389216c --- /dev/null +++ b/homeassistant/components/assist_pipeline/ring_buffer.py @@ -0,0 +1,57 @@ +"""Implementation of a ring buffer using bytearray.""" + + +class RingBuffer: + """Basic ring buffer using a bytearray. + + Not threadsafe. + """ + + def __init__(self, maxlen: int) -> None: + """Initialize empty buffer.""" + self._buffer = bytearray(maxlen) + self._pos = 0 + self._length = 0 + self._maxlen = maxlen + + @property + def maxlen(self) -> int: + """Return the maximum size of the buffer.""" + return self._maxlen + + @property + def pos(self) -> int: + """Return the current put position.""" + return self._pos + + def __len__(self) -> int: + """Return the length of data stored in the buffer.""" + return self._length + + def put(self, data: bytes) -> None: + """Put a chunk of data into the buffer, possibly wrapping around.""" + data_len = len(data) + new_pos = self._pos + data_len + if new_pos >= self._maxlen: + # Split into two chunks + num_bytes_1 = self._maxlen - self._pos + num_bytes_2 = new_pos - self._maxlen + + self._buffer[self._pos : self._maxlen] = data[:num_bytes_1] + self._buffer[:num_bytes_2] = data[num_bytes_1:] + new_pos = new_pos - self._maxlen + else: + # Entire chunk fits at current position + self._buffer[self._pos : self._pos + data_len] = data + + self._pos = new_pos + self._length = min(self._maxlen, self._length + data_len) + + def getvalue(self) -> bytes: + """Get bytes written to the buffer.""" + if (self._pos + self._length) <= self._maxlen: + # Single chunk + return bytes(self._buffer[: self._length]) + + # Two chunks + return bytes(self._buffer[self._pos :] + self._buffer[: self._pos]) diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index cb19811d650..20a048d5621 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,12 +1,15 @@ """Voice activity detection.""" from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, field from enum import StrEnum +from typing import Final import webrtcvad -_SAMPLE_RATE = 16000 +_SAMPLE_RATE: Final = 16000 # Hz +_SAMPLE_WIDTH: Final = 2 # bytes class VadSensitivity(StrEnum): @@ -29,6 +32,45 @@ class VadSensitivity(StrEnum): return 1.0 +class AudioBuffer: + """Fixed-sized audio buffer with variable internal length.""" + + def __init__(self, maxlen: int) -> None: + """Initialize buffer.""" + self._buffer = bytearray(maxlen) + self._length = 0 + + @property + def length(self) -> int: + """Get number of bytes currently in the buffer.""" + return self._length + + def clear(self) -> None: + """Clear the buffer.""" + self._length = 0 + + def append(self, data: bytes) -> None: + """Append bytes to the buffer, increasing the internal length.""" + data_len = len(data) + if (self._length + data_len) > len(self._buffer): + raise ValueError("Length cannot be greater than buffer size") + + self._buffer[self._length : self._length + data_len] = data + self._length += data_len + + def bytes(self) -> bytes: + """Convert written portion of buffer to bytes.""" + return bytes(self._buffer[: self._length]) + + def __len__(self) -> int: + """Get the number of bytes currently in the buffer.""" + return self._length + + def __bool__(self) -> bool: + """Return True if there are bytes in the buffer.""" + return self._length > 0 + + @dataclass class VoiceCommandSegmenter: """Segments an audio stream into voice commands using webrtcvad.""" @@ -36,7 +78,7 @@ class VoiceCommandSegmenter: vad_mode: int = 3 """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" - vad_frames: int = 480 # 30 ms + vad_samples_per_chunk: int = 480 # 30 ms """Must be 10, 20, or 30 ms at 16Khz.""" speech_seconds: float = 0.3 @@ -67,20 +109,23 @@ class VoiceCommandSegmenter: """Seconds left before resetting start/stop time counters.""" _vad: webrtcvad.Vad = None - _audio_buffer: bytes = field(default_factory=bytes) - _bytes_per_chunk: int = 480 * 2 # 16-bit samples - _seconds_per_chunk: float = 0.03 # 30 ms + _leftover_chunk_buffer: AudioBuffer = field(init=False) + _bytes_per_chunk: int = field(init=False) + _seconds_per_chunk: float = field(init=False) def __post_init__(self) -> None: """Initialize VAD.""" self._vad = webrtcvad.Vad(self.vad_mode) - self._bytes_per_chunk = self.vad_frames * 2 - self._seconds_per_chunk = self.vad_frames / _SAMPLE_RATE + self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH + self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE + self._leftover_chunk_buffer = AudioBuffer( + self.vad_samples_per_chunk * _SAMPLE_WIDTH + ) self.reset() def reset(self) -> None: """Reset all counters and state.""" - self._audio_buffer = b"" + self._leftover_chunk_buffer.clear() self._speech_seconds_left = self.speech_seconds self._silence_seconds_left = self.silence_seconds self._timeout_seconds_left = self.timeout_seconds @@ -88,31 +133,24 @@ class VoiceCommandSegmenter: self.in_command = False def process(self, samples: bytes) -> bool: - """Process a 16-bit 16Khz mono audio samples. + """Process 16-bit 16Khz mono audio samples. Returns False when command is done. """ - self._audio_buffer += samples - - # Process in 10, 20, or 30 ms chunks. - num_chunks = len(self._audio_buffer) // self._bytes_per_chunk - for chunk_idx in range(num_chunks): - chunk_offset = chunk_idx * self._bytes_per_chunk - chunk = self._audio_buffer[ - chunk_offset : chunk_offset + self._bytes_per_chunk - ] + for chunk in chunk_samples( + samples, self._bytes_per_chunk, self._leftover_chunk_buffer + ): if not self._process_chunk(chunk): self.reset() return False - if num_chunks > 0: - # Remove from buffer - self._audio_buffer = self._audio_buffer[ - num_chunks * self._bytes_per_chunk : - ] - return True + @property + def audio_buffer(self) -> bytes: + """Get partial chunk in the audio buffer.""" + return self._leftover_chunk_buffer.bytes() + def _process_chunk(self, chunk: bytes) -> bool: """Process a single chunk of 16-bit 16Khz mono audio. @@ -148,3 +186,119 @@ class VoiceCommandSegmenter: self._silence_seconds_left = self.silence_seconds return True + + +@dataclass +class VoiceActivityTimeout: + """Detects silence in audio until a timeout is reached.""" + + silence_seconds: float + """Seconds of silence before timeout.""" + + reset_seconds: float = 0.5 + """Seconds of speech before resetting timeout.""" + + vad_mode: int = 3 + """Aggressiveness in filtering out non-speech. 3 is the most aggressive.""" + + vad_samples_per_chunk: int = 480 # 30 ms + """Must be 10, 20, or 30 ms at 16Khz.""" + + _silence_seconds_left: float = 0.0 + """Seconds left before considering voice command as stopped.""" + + _reset_seconds_left: float = 0.0 + """Seconds left before resetting start/stop time counters.""" + + _vad: webrtcvad.Vad = None + _leftover_chunk_buffer: AudioBuffer = field(init=False) + _bytes_per_chunk: int = field(init=False) + _seconds_per_chunk: float = field(init=False) + + def __post_init__(self) -> None: + """Initialize VAD.""" + self._vad = webrtcvad.Vad(self.vad_mode) + self._bytes_per_chunk = self.vad_samples_per_chunk * _SAMPLE_WIDTH + self._seconds_per_chunk = self.vad_samples_per_chunk / _SAMPLE_RATE + self._leftover_chunk_buffer = AudioBuffer( + self.vad_samples_per_chunk * _SAMPLE_WIDTH + ) + self.reset() + + def reset(self) -> None: + """Reset all counters and state.""" + self._leftover_chunk_buffer.clear() + self._silence_seconds_left = self.silence_seconds + self._reset_seconds_left = self.reset_seconds + + def process(self, samples: bytes) -> bool: + """Process 16-bit 16Khz mono audio samples. + + Returns False when timeout is reached. + """ + for chunk in chunk_samples( + samples, self._bytes_per_chunk, self._leftover_chunk_buffer + ): + if not self._process_chunk(chunk): + return False + + return True + + def _process_chunk(self, chunk: bytes) -> bool: + """Process a single chunk of 16-bit 16Khz mono audio. + + Returns False when timeout is reached. + """ + if self._vad.is_speech(chunk, _SAMPLE_RATE): + # Speech + self._reset_seconds_left -= self._seconds_per_chunk + if self._reset_seconds_left <= 0: + # Reset timeout + self._silence_seconds_left = self.silence_seconds + else: + # Silence + self._silence_seconds_left -= self._seconds_per_chunk + if self._silence_seconds_left <= 0: + # Timeout reached + return False + + # Slowly build reset counter back up + self._reset_seconds_left = min( + self.reset_seconds, self._reset_seconds_left + self._seconds_per_chunk + ) + + return True + + +def chunk_samples( + samples: bytes, + bytes_per_chunk: int, + leftover_chunk_buffer: AudioBuffer, +) -> Iterable[bytes]: + """Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s).""" + + if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk: + # Extend leftover chunk, but not enough samples to complete it + leftover_chunk_buffer.append(samples) + return + + next_chunk_idx = 0 + + if leftover_chunk_buffer: + # Add to leftover chunk from previous call(s). + bytes_to_copy = bytes_per_chunk - len(leftover_chunk_buffer) + leftover_chunk_buffer.append(samples[:bytes_to_copy]) + next_chunk_idx = bytes_to_copy + + # Process full chunk in buffer + yield leftover_chunk_buffer.bytes() + leftover_chunk_buffer.clear() + + while next_chunk_idx < len(samples) - bytes_per_chunk + 1: + # Process full chunk + yield samples[next_chunk_idx : next_chunk_idx + bytes_per_chunk] + next_chunk_idx += bytes_per_chunk + + # Capture leftover chunks + if rest_samples := samples[next_chunk_idx:]: + leftover_chunk_buffer.append(rest_samples) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 4e6d44a8868..57e2cc8b398 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -7,7 +7,6 @@ from collections.abc import AsyncGenerator, Callable import logging from typing import Any -import async_timeout import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api @@ -26,11 +25,12 @@ from .pipeline import ( PipelineInput, PipelineRun, PipelineStage, + WakeWordSettings, async_get_pipeline, ) -from .vad import VoiceCommandSegmenter DEFAULT_TIMEOUT = 30 +DEFAULT_WAKE_WORD_TIMEOUT = 3 _LOGGER = logging.getLogger(__name__) @@ -63,6 +63,18 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: cv.key_value_schemas( "start_stage", { + PipelineStage.WAKE_WORD: vol.Schema( + { + vol.Required("input"): { + vol.Required("sample_rate"): int, + vol.Optional("timeout"): vol.Any(float, int), + vol.Optional("audio_seconds_to_buffer"): vol.Any( + float, int + ), + } + }, + extra=vol.ALLOW_EXTRA, + ), PipelineStage.STT: vol.Schema( {vol.Required("input"): {vol.Required("sample_rate"): int}}, extra=vol.ALLOW_EXTRA, @@ -102,6 +114,7 @@ async def websocket_run( end_stage = PipelineStage(msg["end_stage"]) handler_id: int | None = None unregister_handler: Callable[[], None] | None = None + wake_word_settings: WakeWordSettings | None = None # Arguments to PipelineInput input_args: dict[str, Any] = { @@ -109,24 +122,26 @@ async def websocket_run( "device_id": msg.get("device_id"), } - if start_stage == PipelineStage.STT: + if start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT): # Audio pipeline that will receive audio as binary websocket messages audio_queue: asyncio.Queue[bytes] = asyncio.Queue() incoming_sample_rate = msg["input"]["sample_rate"] + if start_stage == PipelineStage.WAKE_WORD: + wake_word_settings = WakeWordSettings( + timeout=msg["input"].get("timeout", DEFAULT_WAKE_WORD_TIMEOUT), + audio_seconds_to_buffer=msg["input"].get("audio_seconds_to_buffer", 0), + ) + async def stt_stream() -> AsyncGenerator[bytes, None]: state = None - segmenter = VoiceCommandSegmenter() # Yield until we receive an empty chunk while chunk := await audio_queue.get(): - chunk, state = audioop.ratecv( - chunk, 2, 1, incoming_sample_rate, 16000, state - ) - if not segmenter.process(chunk): - # Voice command is finished - break - + if incoming_sample_rate != 16000: + chunk, state = audioop.ratecv( + chunk, 2, 1, incoming_sample_rate, 16000, state + ) yield chunk def handle_binary( @@ -169,6 +184,7 @@ async def websocket_run( "stt_binary_handler_id": handler_id, "timeout": timeout, }, + wake_word_settings=wake_word_settings, ) pipeline_input = PipelineInput(**input_args) @@ -190,7 +206,7 @@ async def websocket_run( try: # Task contains a timeout - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await run_task except asyncio.TimeoutError: pipeline_input.run.process_event( diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 8f7229bf5ad..c6fe651d292 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -15,9 +15,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index e8deca6f04d..2d04ca798e0 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -1,15 +1,15 @@ """The ATAG Integration.""" +from asyncio import timeout from datetime import timedelta import logging -import async_timeout from pyatag import AtagException, AtagOne from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_data(): """Update data via library.""" - async with async_timeout.timeout(20): + async with timeout(20): try: await atag.update() except AtagException as err: diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index cdf45db035c..3293a3e7a09 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error +from atenpdu import AtenPE, AtenPEError import voluptuous as vol from homeassistant.components.switch import ( @@ -16,8 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 8738b58dab9..408d6e0be7e 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio from collections.abc import ValuesView +from datetime import datetime from itertools import chain import logging +from typing import Any from aiohttp import ClientError, ClientResponseError from yalexs.const import DEFAULT_BRAND @@ -238,14 +240,18 @@ class AugustData(AugustSubscriberMixin): ) @callback - def async_pubnub_message(self, device_id, date_time, message): + def async_pubnub_message( + self, device_id: str, date_time: datetime, message: dict[str, Any] + ) -> None: """Process a pubnub message.""" 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: - self.activity_stream.async_process_newer_device_activities(activities) + activity_stream.async_process_newer_device_activities(activities) self.async_signal_device_id_update(device.device_id) - self.activity_stream.async_schedule_house_id_refresh(device.house_id) + activity_stream.async_schedule_house_id_refresh(device.house_id) @callback def async_stop(self): diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index ad9045a3d0d..fdb399f0646 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -1,16 +1,22 @@ """Consume the august activity stream.""" import asyncio +from datetime import datetime +from functools import partial import logging from aiohttp import ClientError +from yalexs.activity import Activity, ActivityType +from yalexs.api_async import ApiAsync +from yalexs.pubnub_async import AugustPubNub from yalexs.util import get_latest_activity -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.event import async_call_later from homeassistant.util.dt import utcnow from .const import ACTIVITY_UPDATE_INTERVAL +from .gateway import AugustGateway from .subscriber import AugustSubscriberMixin _LOGGER = logging.getLogger(__name__) @@ -18,117 +24,138 @@ _LOGGER = logging.getLogger(__name__) ACTIVITY_STREAM_FETCH_LIMIT = 10 ACTIVITY_CATCH_UP_FETCH_LIMIT = 2500 +# If there is a storm of activity (ie lock, unlock, door open, door close, etc) +# we want to debounce the updates so we don't hammer the activity api too much. +ACTIVITY_DEBOUNCE_COOLDOWN = 3 + + +@callback +def _async_cancel_future_scheduled_updates(cancels: list[CALLBACK_TYPE]) -> None: + """Cancel future scheduled updates.""" + for cancel in cancels: + cancel() + cancels.clear() + class ActivityStream(AugustSubscriberMixin): """August activity stream handler.""" - def __init__(self, hass, api, august_gateway, house_ids, pubnub): + def __init__( + self, + hass: HomeAssistant, + api: ApiAsync, + august_gateway: AugustGateway, + house_ids: set[str], + pubnub: AugustPubNub, + ) -> None: """Init August activity stream object.""" super().__init__(hass, ACTIVITY_UPDATE_INTERVAL) self._hass = hass - self._schedule_updates = {} + self._schedule_updates: dict[str, list[CALLBACK_TYPE]] = {} self._august_gateway = august_gateway self._api = api self._house_ids = house_ids - self._latest_activities = {} - self._last_update_time = None + self._latest_activities: dict[str, dict[ActivityType, Activity]] = {} + self._did_first_update = False self.pubnub = pubnub - self._update_debounce = {} + self._update_debounce: dict[str, Debouncer] = {} + self._update_debounce_jobs: dict[str, HassJob] = {} - async def async_setup(self): + async def _async_update_house_id_later( + self, debouncer: Debouncer, _: datetime + ) -> None: + """Call a debouncer from async_call_later.""" + await debouncer.async_call() + + async def async_setup(self) -> None: """Token refresh check and catch up the activity stream.""" + update_debounce = self._update_debounce + update_debounce_jobs = self._update_debounce_jobs for house_id in self._house_ids: - self._update_debounce[house_id] = self._async_create_debouncer(house_id) + debouncer = Debouncer( + self._hass, + _LOGGER, + cooldown=ACTIVITY_DEBOUNCE_COOLDOWN, + immediate=True, + function=partial(self._async_update_house_id, house_id), + ) + update_debounce[house_id] = debouncer + update_debounce_jobs[house_id] = HassJob( + partial(self._async_update_house_id_later, debouncer), + f"debounced august activity update for {house_id}", + cancel_on_shutdown=True, + ) await self._async_refresh(utcnow()) + self._did_first_update = True @callback - def _async_create_debouncer(self, house_id): - """Create a debouncer for the house id.""" - - async def _async_update_house_id(): - await self._async_update_house_id(house_id) - - return Debouncer( - self._hass, - _LOGGER, - cooldown=ACTIVITY_UPDATE_INTERVAL.total_seconds(), - immediate=True, - function=_async_update_house_id, - ) - - @callback - def async_stop(self): + def async_stop(self) -> None: """Cleanup any debounces.""" for debouncer in self._update_debounce.values(): debouncer.async_cancel() - for house_id, updater in self._schedule_updates.items(): - if updater is not None: - updater() - self._schedule_updates[house_id] = None + for cancels in self._schedule_updates.values(): + _async_cancel_future_scheduled_updates(cancels) - def get_latest_device_activity(self, device_id, activity_types): + def get_latest_device_activity( + self, device_id: str, activity_types: set[ActivityType] + ) -> Activity | None: """Return latest activity that is one of the activity_types.""" - if device_id not in self._latest_activities: + if not (latest_device_activities := self._latest_activities.get(device_id)): return None - latest_device_activities = self._latest_activities[device_id] - latest_activity = None + latest_activity: Activity | None = None for activity_type in activity_types: - if activity_type in latest_device_activities: + if activity := latest_device_activities.get(activity_type): if ( - latest_activity is not None - and latest_device_activities[activity_type].activity_start_time + latest_activity + and activity.activity_start_time <= latest_activity.activity_start_time ): continue - latest_activity = latest_device_activities[activity_type] + latest_activity = activity return latest_activity - async def _async_refresh(self, time): + async def _async_refresh(self, time: datetime) -> None: """Update the activity stream from August.""" # This is the only place we refresh the api token await self._august_gateway.async_refresh_access_token_if_needed() if self.pubnub.connected: _LOGGER.debug("Skipping update because pubnub is connected") return - await self._async_update_device_activities(time) - - async def _async_update_device_activities(self, time): _LOGGER.debug("Start retrieving device activities") await asyncio.gather( - *( - self._update_debounce[house_id].async_call() - for house_id in self._house_ids - ) + *(debouncer.async_call() for debouncer in self._update_debounce.values()) ) - self._last_update_time = time @callback - def async_schedule_house_id_refresh(self, house_id): + def async_schedule_house_id_refresh(self, house_id: str) -> None: """Update for a house activities now and once in the future.""" - if self._schedule_updates.get(house_id): - self._schedule_updates[house_id]() - self._schedule_updates[house_id] = None + if future_updates := self._schedule_updates.setdefault(house_id, []): + _async_cancel_future_scheduled_updates(future_updates) - async def _update_house_activities(_): - await self._update_debounce[house_id].async_call() + debouncer = self._update_debounce[house_id] + self._hass.async_create_task(debouncer.async_call()) + # Schedule two updates past the debounce time + # to ensure we catch the case where the activity + # api does not update right away and we need to poll + # it again. Sometimes the lock operator or a doorbell + # will not show up in the activity stream right away. + job = self._update_debounce_jobs[house_id] + for step in (1, 2): + future_updates.append( + async_call_later( + self._hass, + (step * ACTIVITY_DEBOUNCE_COOLDOWN) + 0.1, + job, + ) + ) - self._hass.async_create_task(self._update_debounce[house_id].async_call()) - # Schedule an update past the debounce to ensure - # we catch the case where the lock operator is - # not updated or the lock failed - self._schedule_updates[house_id] = async_call_later( - self._hass, - ACTIVITY_UPDATE_INTERVAL.total_seconds() + 1, - _update_house_activities, - ) - - async def _async_update_house_id(self, house_id): + async def _async_update_house_id(self, house_id: str) -> None: """Update device activities for a house.""" - if self._last_update_time: + if self._did_first_update: limit = ACTIVITY_STREAM_FETCH_LIMIT else: limit = ACTIVITY_CATCH_UP_FETCH_LIMIT @@ -150,36 +177,34 @@ class ActivityStream(AugustSubscriberMixin): _LOGGER.debug( "Completed retrieving device activities for house id %s", house_id ) - - updated_device_ids = self.async_process_newer_device_activities(activities) - - if not updated_device_ids: - return - - for device_id in updated_device_ids: + for device_id in self.async_process_newer_device_activities(activities): _LOGGER.debug( "async_signal_device_id_update (from activity stream): %s", device_id, ) self.async_signal_device_id_update(device_id) - def async_process_newer_device_activities(self, activities): + def async_process_newer_device_activities( + self, activities: list[Activity] + ) -> set[str]: """Process activities if they are newer than the last one.""" updated_device_ids = set() + latest_activities = self._latest_activities for activity in activities: device_id = activity.device_id activity_type = activity.activity_type - device_activities = self._latest_activities.setdefault(device_id, {}) + device_activities = latest_activities.setdefault(device_id, {}) # Ignore activities that are older than the latest one unless it is a non # locking or unlocking activity with the exact same start time. - if ( - get_latest_activity(activity, device_activities.get(activity_type)) - != activity - ): + last_activity = device_activities.get(activity_type) + # The activity stream can have duplicate activities. So we need + # to call get_latest_activity to figure out if if the activity + # is actually newer than the last one. + latest_activity = get_latest_activity(activity, last_activity) + if latest_activity != activity: continue device_activities[activity_type] = activity - updated_device_ids.add(device_id) return updated_device_ids diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index c6f406a5094..b19a9833a47 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -13,7 +13,7 @@ from yalexs.activity import ( ActivityType, ) from yalexs.doorbell import Doorbell, DoorbellDetail -from yalexs.lock import Lock, LockDoorStatus +from yalexs.lock import Lock, LockDetail, LockDoorStatus from yalexs.util import update_lock_detail_from_activity from homeassistant.components.binary_sensor import ( @@ -39,13 +39,16 @@ TIME_TO_RECHECK_DETECTION = timedelta( ) -def _retrieve_online_state(data: AugustData, detail: DoorbellDetail) -> bool: +def _retrieve_online_state( + data: AugustData, detail: DoorbellDetail | LockDetail +) -> bool: """Get the latest state of the sensor.""" # The doorbell will go into standby mode when there is no motion # for a short while. It will wake by itself when needed so we need # to consider is available or we will not report motion or dings - - return detail.is_online or detail.is_standby + if isinstance(detail, DoorbellDetail): + return detail.is_online or detail.is_standby + return detail.bridge_is_online def _retrieve_motion_state(data: AugustData, detail: DoorbellDetail) -> bool: @@ -72,7 +75,7 @@ def _retrieve_image_capture_state(data: AugustData, detail: DoorbellDetail) -> b return _activity_time_based_state(latest) -def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail) -> bool: +def _retrieve_ding_state(data: AugustData, detail: DoorbellDetail | LockDetail) -> bool: assert data.activity_stream is not None latest = data.activity_stream.get_latest_device_activity( detail.device_id, {ActivityType.DOORBELL_DING} @@ -106,10 +109,6 @@ def _native_datetime() -> datetime: class AugustBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes August binary_sensor entity.""" - # AugustBinarySensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - @dataclass class AugustDoorbellRequiredKeysMixin: @@ -125,42 +124,28 @@ class AugustDoorbellBinarySensorEntityDescription( ): """Describes August binary_sensor entity.""" - # AugustDoorbellBinarySensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - SENSOR_TYPE_DOOR = AugustBinarySensorEntityDescription( - key="door_open", - name="Open", + key="open", + device_class=BinarySensorDeviceClass.DOOR, ) - -SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = ( +SENSOR_TYPES_VIDEO_DOORBELL = ( AugustDoorbellBinarySensorEntityDescription( - key="doorbell_ding", - name="Ding", - device_class=BinarySensorDeviceClass.OCCUPANCY, - value_fn=_retrieve_ding_state, - is_time_based=True, - ), - AugustDoorbellBinarySensorEntityDescription( - key="doorbell_motion", - name="Motion", + key="motion", device_class=BinarySensorDeviceClass.MOTION, value_fn=_retrieve_motion_state, is_time_based=True, ), AugustDoorbellBinarySensorEntityDescription( - key="doorbell_image_capture", - name="Image Capture", + key="image capture", + translation_key="image_capture", icon="mdi:file-image", value_fn=_retrieve_image_capture_state, is_time_based=True, ), AugustDoorbellBinarySensorEntityDescription( - key="doorbell_online", - name="Online", + key="online", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_online_state, @@ -169,6 +154,16 @@ SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = ) +SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = ( + AugustDoorbellBinarySensorEntityDescription( + key="ding", + device_class=BinarySensorDeviceClass.OCCUPANCY, + value_fn=_retrieve_ding_state, + is_time_based=True, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -193,8 +188,17 @@ async def async_setup_entry( _LOGGER.debug("Adding sensor class door for %s", door.device_name) entities.append(AugustDoorBinarySensor(data, door, SENSOR_TYPE_DOOR)) + if detail.doorbell: + for description in SENSOR_TYPES_DOORBELL: + _LOGGER.debug( + "Adding doorbell sensor class %s for %s", + description.device_class, + door.device_name, + ) + entities.append(AugustDoorbellBinarySensor(data, door, description)) + for doorbell in data.doorbells: - for description in SENSOR_TYPES_DOORBELL: + for description in SENSOR_TYPES_DOORBELL + SENSOR_TYPES_VIDEO_DOORBELL: _LOGGER.debug( "Adding doorbell sensor class %s for %s", description.device_class, @@ -221,8 +225,7 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): self.entity_description = description self._data = data self._device = device - self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" + self._attr_unique_id = f"{self._device_id}_{description.key}" @callback def _update_from_data(self): @@ -261,7 +264,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): def __init__( self, data: AugustData, - device: Doorbell, + device: Doorbell | Lock, description: AugustDoorbellBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" @@ -269,8 +272,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self.entity_description = description self._check_for_off_update_listener = None self._data = data - self._attr_name = f"{device.device_name} {description.name}" - self._attr_unique_id = f"{self._device_id}_{description.name.lower()}" + self._attr_unique_id = f"{self._device_id}_{description.key}" @callback def _update_from_data(self): diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index c96db61ca1a..b8f66aea02b 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -24,10 +24,11 @@ async def async_setup_entry( class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): """Representation of an August lock wake button.""" + _attr_translation_key = "wake" + def __init__(self, data: AugustData, device: Lock) -> None: """Initialize the lock wake button.""" super().__init__(data, device) - self._attr_name = f"{device.device_name} Wake" self._attr_unique_id = f"{self._device_id}_wake" async def async_press(self) -> None: diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index a3cc18ab9c0..e618c2d49d5 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -33,33 +33,26 @@ async def async_setup_entry( class AugustCamera(AugustEntityMixin, Camera): - """An implementation of a August security camera.""" + """An implementation of an August security camera.""" + + _attr_translation_key = "camera" def __init__(self, data, device, session, timeout): - """Initialize a August security camera.""" + """Initialize an August security camera.""" super().__init__(data, device) self._timeout = timeout self._session = session self._image_url = None self._image_content = None - self._attr_name = f"{device.device_name} Camera" self._attr_unique_id = f"{self._device_id:s}_camera" + self._attr_motion_detection_enabled = True + self._attr_brand = DEFAULT_NAME @property def is_recording(self) -> bool: """Return true if the device is recording.""" return self._device.has_subscription - @property - def motion_detection_enabled(self) -> bool: - """Return the camera motion detection status.""" - return True - - @property - def brand(self): - """Return the camera brand.""" - return DEFAULT_NAME - @property def model(self): """Return the camera model.""" diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 0b7a42267d8..47f3b8be74f 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -6,7 +6,8 @@ from yalexs.lock import Lock from yalexs.util import get_configuration_url from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from . import DOMAIN, AugustData from .const import MANUFACTURER @@ -18,6 +19,7 @@ class AugustEntityMixin(Entity): """Base implementation for August device.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, data: AugustData, device: Doorbell | Lock) -> None: """Initialize an August device.""" diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 9e8b2470b4e..e082cd1cfab 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -37,11 +37,12 @@ async def async_setup_entry( class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): """Representation of an August lock.""" + _attr_name = None + def __init__(self, data, device): """Initialize the lock.""" super().__init__(data, device) self._lock_status = None - self._attr_name = device.device_name self._attr_unique_id = f"{self._device_id:s}_lock" self._update_from_data() diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 0dbc4c8f7d6..cd2737adca3 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.5.1", "yalexs-ble==2.2.3"] + "requirements": ["yalexs==1.8.0", "yalexs-ble==2.2.3"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 169a344e2bd..12ed3a88558 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -75,7 +75,6 @@ class AugustSensorEntityDescription( SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( key="device_battery", - name="Battery", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=_retrieve_device_battery_state, @@ -83,7 +82,6 @@ SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail]( key="linked_keypad_battery", - name="Battery", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, value_fn=_retrieve_linked_keypad_battery_state, @@ -176,6 +174,8 @@ async def _async_migrate_old_unique_ids(hass, devices): class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): """Representation of an August lock operation sensor.""" + _attr_translation_key = "operator" + def __init__(self, data, device): """Initialize the sensor.""" super().__init__(data, device) @@ -185,14 +185,9 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._operated_keypad = None self._operated_autorelock = None self._operated_time = None - self._entity_picture = None + self._attr_unique_id = f"{self._device_id}_lock_operator" self._update_from_data() - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._device.device_name} Operator" - @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" @@ -206,7 +201,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad self._operated_autorelock = lock_activity.operated_autorelock - self._entity_picture = lock_activity.operator_thumbnail_url + self._attr_entity_picture = lock_activity.operator_thumbnail_url @property def extra_state_attributes(self): @@ -241,7 +236,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._attr_native_value = last_state.state if ATTR_ENTITY_PICTURE in last_state.attributes: - self._entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] + self._attr_entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] if ATTR_OPERATION_REMOTE in last_state.attributes: self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE] if ATTR_OPERATION_KEYPAD in last_state.attributes: @@ -249,16 +244,6 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): if ATTR_OPERATION_AUTORELOCK in last_state.attributes: self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK] - @property - def entity_picture(self): - """Return the entity picture to use in the frontend, if any.""" - return self._entity_picture - - @property - def unique_id(self) -> str: - """Get the unique id of the device sensor.""" - return f"{self._device_id}_lock_operator" - class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]): """Representation of an August sensor.""" @@ -277,9 +262,8 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]): """Initialize the sensor.""" super().__init__(data, device) self.entity_description = description - self._old_device = old_device - self._attr_name = f"{device.device_name} {description.name}" self._attr_unique_id = f"{self._device_id}_{description.key}" + self.old_unique_id = f"{old_device.device_id}_{description.key}" self._update_from_data() @callback @@ -287,8 +271,3 @@ class AugustBatterySensor(AugustEntityMixin, SensorEntity, Generic[_T]): """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 - - @property - def old_unique_id(self) -> str: - """Get the old unique id of the device sensor.""" - return f"{self._old_device.device_id}_{self.entity_description.key}" diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json index 88362c9fd66..7e33ec30881 100644 --- a/homeassistant/components/august/strings.json +++ b/homeassistant/components/august/strings.json @@ -37,5 +37,27 @@ "title": "Reauthenticate an August account" } } + }, + "entity": { + "binary_sensor": { + "image_capture": { + "name": "Image capture" + } + }, + "button": { + "wake": { + "name": "Wake" + } + }, + "camera": { + "camera": { + "name": "[%key:component::camera::title%]" + } + }, + "sensor": { + "operator": { + "name": "Operator" + } + } } } diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 62aef44a9ee..138887ed09e 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -1,25 +1,30 @@ """Base class for August entity.""" +from abc import abstractmethod +from datetime import datetime, timedelta + from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval class AugustSubscriberMixin: """Base implementation for a subscriber.""" - def __init__(self, hass, update_interval): + def __init__(self, hass: HomeAssistant, update_interval: timedelta) -> None: """Initialize an subscriber.""" super().__init__() self._hass = hass self._update_interval = update_interval - self._subscriptions = {} - self._unsub_interval = None - self._stop_interval = None + self._subscriptions: dict[str, list[CALLBACK_TYPE]] = {} + self._unsub_interval: CALLBACK_TYPE | None = None + self._stop_interval: CALLBACK_TYPE | None = None @callback - def async_subscribe_device_id(self, device_id, update_callback): + def async_subscribe_device_id( + self, device_id: str, update_callback: CALLBACK_TYPE + ) -> CALLBACK_TYPE: """Add an callback subscriber. Returns a callable that can be used to unsubscribe. @@ -34,8 +39,12 @@ class AugustSubscriberMixin: return _unsubscribe + @abstractmethod + async def _async_refresh(self, time: datetime) -> None: + """Refresh data.""" + @callback - def _async_setup_listeners(self): + def _async_setup_listeners(self) -> None: """Create interval and stop listeners.""" self._unsub_interval = async_track_time_interval( self._hass, @@ -54,7 +63,9 @@ class AugustSubscriberMixin: ) @callback - def async_unsubscribe_device_id(self, device_id, update_callback): + def async_unsubscribe_device_id( + self, device_id: str, update_callback: CALLBACK_TYPE + ) -> None: """Remove a callback subscriber.""" self._subscriptions[device_id].remove(update_callback) if not self._subscriptions[device_id]: @@ -63,14 +74,15 @@ class AugustSubscriberMixin: if self._subscriptions: return - self._unsub_interval() - self._unsub_interval = None + if self._unsub_interval: + self._unsub_interval() + self._unsub_interval = None if self._stop_interval: self._stop_interval() self._stop_interval = None @callback - def async_signal_device_id_update(self, device_id): + def async_signal_device_id_update(self, device_id: str) -> None: """Call the callbacks for a device_id.""" if not self._subscriptions.get(device_id): return diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index db054910d9a..6ffba5f13da 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -9,14 +9,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, Platfo from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import ( - AURORA_API, - CONF_THRESHOLD, - COORDINATOR, - DEFAULT_POLLING_INTERVAL, - DEFAULT_THRESHOLD, - DOMAIN, -) +from .const import AURORA_API, CONF_THRESHOLD, COORDINATOR, DEFAULT_THRESHOLD, DOMAIN from .coordinator import AuroraDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,14 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = conf[CONF_LONGITUDE] latitude = conf[CONF_LATITUDE] - polling_interval = DEFAULT_POLLING_INTERVAL threshold = options.get(CONF_THRESHOLD, DEFAULT_THRESHOLD) name = conf[CONF_NAME] coordinator = AuroraDataUpdateCoordinator( hass=hass, name=name, - polling_interval=polling_interval, api=api, latitude=latitude, longitude=longitude, diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index a0e09685a0f..d817ea51988 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -13,9 +13,12 @@ async def async_setup_entry( ) -> None: """Set up the binary_sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] - name = f"{coordinator.name} Aurora Visibility Alert" - entity = AuroraSensor(coordinator=coordinator, name=name, icon="mdi:hazard-lights") + entity = AuroraSensor( + coordinator=coordinator, + translation_key="visibility_alert", + icon="mdi:hazard-lights", + ) async_add_entries([entity]) diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 4649a3adc08..bbd0768e74a 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, @@ -75,24 +75,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema( + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_NAME): str, + vol.Required(CONF_LONGITUDE): cv.longitude, + vol.Required(CONF_LATITUDE): cv.latitude, + } + ), { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required( - CONF_LONGITUDE, - default=self.hass.config.longitude, - ): vol.All( - vol.Coerce(float), - vol.Range(min=-180, max=180), - ), - vol.Required( - CONF_LATITUDE, - default=self.hass.config.latitude, - ): vol.All( - vol.Coerce(float), - vol.Range(min=-90, max=90), - ), - } + CONF_NAME: DEFAULT_NAME, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_LATITUDE: self.hass.config.latitude, + }, ), errors=errors, ) diff --git a/homeassistant/components/aurora/const.py b/homeassistant/components/aurora/const.py index d2f91fb1222..419a3c946e6 100644 --- a/homeassistant/components/aurora/const.py +++ b/homeassistant/components/aurora/const.py @@ -3,7 +3,6 @@ DOMAIN = "aurora" COORDINATOR = "coordinator" AURORA_API = "aurora_api" -DEFAULT_POLLING_INTERVAL = 5 CONF_THRESHOLD = "forecast_threshold" DEFAULT_THRESHOLD = 75 ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration" diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index c126e2a8c68..0ab1be00902 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -7,10 +7,7 @@ from aiohttp import ClientError from auroranoaa import AuroraForecast from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import ( - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) @@ -22,7 +19,6 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): self, hass: HomeAssistant, name: str, - polling_interval: int, api: AuroraForecast, latitude: float, longitude: float, @@ -34,7 +30,7 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): hass=hass, logger=_LOGGER, name=name, - update_interval=timedelta(minutes=polling_interval), + update_interval=timedelta(minutes=5), ) self.api = api diff --git a/homeassistant/components/aurora/entity.py b/homeassistant/components/aurora/entity.py index 8948ff9c43c..a52f523f667 100644 --- a/homeassistant/components/aurora/entity.py +++ b/homeassistant/components/aurora/entity.py @@ -2,16 +2,10 @@ import logging -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, -) +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTRIBUTION, - DOMAIN, -) +from .const import ATTRIBUTION, DOMAIN from .coordinator import AuroraDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -25,14 +19,14 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]): def __init__( self, coordinator: AuroraDataUpdateCoordinator, - name: str, + translation_key: str, icon: str, ) -> None: """Initialize the Aurora Entity.""" super().__init__(coordinator=coordinator) - self._attr_name = name + self._attr_translation_key = translation_key self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" self._attr_icon = icon diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index a5436e1e219..f44cc23f832 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -17,7 +17,7 @@ async def async_setup_entry( entity = AuroraSensor( coordinator=coordinator, - name=f"{coordinator.name} Aurora Visibility %", + translation_key="visibility", icon="mdi:gauge", ) diff --git a/homeassistant/components/aurora/strings.json b/homeassistant/components/aurora/strings.json index 9beb9c7906d..09ec86bdf4d 100644 --- a/homeassistant/components/aurora/strings.json +++ b/homeassistant/components/aurora/strings.json @@ -25,5 +25,17 @@ } } } + }, + "entity": { + "binary_sensor": { + "visibility_alert": { + "name": "Visibility alert" + } + }, + "sensor": { + "visibility": { + "name": "Visibility" + } + } } } diff --git a/homeassistant/components/aurora_abb_powerone/aurora_device.py b/homeassistant/components/aurora_abb_powerone/aurora_device.py index 6d3260a45f4..e9ca9e47121 100644 --- a/homeassistant/components/aurora_abb_powerone/aurora_device.py +++ b/homeassistant/components/aurora_abb_powerone/aurora_device.py @@ -7,7 +7,8 @@ from typing import Any from aurorapy.client import AuroraSerialClient -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import ( ATTR_DEVICE_NAME, diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index ae4bc78580c..1bdb0579976 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -3,10 +3,11 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from aiohttp import ClientError from aussiebb.asyncio import AussieBB -from aussiebb.const import FETCH_TYPES +from aussiebb.const import FETCH_TYPES, NBN_TYPES, PHONE_TYPES from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType from homeassistant.config_entries import ConfigEntry @@ -22,6 +23,19 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] +# Backport for the pyaussiebb=0.0.15 validate_service_type method +def validate_service_type(service: dict[str, Any]) -> None: + """Check the service types against known types.""" + + if "type" not in service: + raise ValueError("Field 'type' not found in service data") + if service["type"] not in NBN_TYPES + PHONE_TYPES + ["Hardware"]: + raise UnrecognisedServiceType( + f"Service type {service['type']=} {service['name']=} - not recognised - ", + "please report this at https://github.com/yaleman/aussiebb/issues/new", + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aussie Broadband from a config entry.""" # Login to the Aussie Broadband API and retrieve the current service list @@ -30,6 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) + # Overwrite the pyaussiebb=0.0.15 validate_service_type method with backport + # Required until pydantic 2.x is supported + client.validate_service_type = validate_service_type try: await client.login() services = await client.get_services(drop_types=FETCH_TYPES) diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index fa407949b40..aff232f2934 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfInformation, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index deaf3b7892d..78a1383012d 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -124,9 +124,11 @@ 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 +from logging import getLogger from typing import Any, cast import uuid @@ -138,6 +140,7 @@ from homeassistant.auth import InvalidAuthError from homeassistant.auth.models import ( TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, Credentials, + RefreshToken, User, ) from homeassistant.components import websocket_api @@ -188,6 +191,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_create_long_lived_access_token) websocket_api.async_register_command(hass, websocket_refresh_tokens) websocket_api.async_register_command(hass, websocket_delete_refresh_token) + websocket_api.async_register_command(hass, websocket_delete_all_refresh_tokens) websocket_api.async_register_command(hass, websocket_sign_path) await login_flow.async_setup(hass, store_result) @@ -598,6 +602,50 @@ async def websocket_delete_refresh_token( connection.send_result(msg["id"], {}) +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/delete_all_refresh_tokens", + } +) +@websocket_api.ws_require_user() +@websocket_api.async_response +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(): + 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 remove_failed: + connection.send_error( + msg["id"], "token_removing_error", "During removal, an error was raised." + ) + else: + connection.send_result(msg["id"], {}) + + hass.async_create_task(hass.auth.async_remove_refresh_token(current_refresh_token)) + + @websocket_api.websocket_command( { vol.Required("type"): "auth/sign_path", diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index 5b306b058d3..a33fbfeab79 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -import avea # pylint: disable=import-error +import avea from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index dca885ffe0d..083c7d48b03 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -1,23 +1,18 @@ """The awair component.""" from __future__ import annotations -from asyncio import gather +from asyncio import gather, timeout from dataclasses import dataclass from datetime import timedelta from aiohttp import ClientSession -from async_timeout import timeout from python_awair import Awair, AwairLocal from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice from python_awair.exceptions import AuthError, AwairError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_HOST, - Platform, -) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index ee0febf1455..27962167330 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 35d20258ead..c93a8493845 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.1.0"] + "requirements": ["aiobotocore==2.6.0"] } diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 067014cc81f..4cc81947e27 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow +from homeassistant.helpers.event import async_call_later from .const import DOMAIN as AXIS_DOMAIN from .device import AxisNetworkDevice @@ -95,10 +94,10 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): self.async_write_ha_state() return - self.cancel_scheduled_update = async_track_point_in_utc_time( + self.cancel_scheduled_update = async_call_later( self.hass, + timedelta(seconds=self.device.option_trigger_time), scheduled_update, - utcnow() + timedelta(seconds=self.device.option_trigger_time), ) @callback diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 53e2c3c9fe5..0b3a93f24fc 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -71,18 +71,30 @@ class AxisCamera(AxisEntity, MjpegCamera): Additionally used when device change IP address. """ image_options = self.generate_options(skip_stream_profile=True) - self._still_image_url = f"http://{self.device.host}:{self.device.port}/axis-cgi/jpg/image.cgi{image_options}" + self._still_image_url = ( + f"http://{self.device.host}:{self.device.port}/axis-cgi" + f"/jpg/image.cgi{image_options}" + ) mjpeg_options = self.generate_options() - self._mjpeg_url = f"http://{self.device.host}:{self.device.port}/axis-cgi/mjpg/video.cgi{mjpeg_options}" + self._mjpeg_url = ( + f"http://{self.device.host}:{self.device.port}/axis-cgi" + f"/mjpg/video.cgi{mjpeg_options}" + ) stream_options = self.generate_options(add_video_codec_h264=True) - self._stream_source = f"rtsp://{self.device.username}:{self.device.password}@{self.device.host}/axis-media/media.amp{stream_options}" + self._stream_source = ( + f"rtsp://{self.device.username}:{self.device.password}" + f"@{self.device.host}/axis-media/media.amp{stream_options}" + ) self.device.additional_diagnostics["camera_sources"] = { "Image": self._still_image_url, "MJPEG": self._mjpeg_url, - "Stream": f"rtsp://user:pass@{self.device.host}/axis-media/media.amp{stream_options}", + "Stream": ( + f"rtsp://user:pass@{self.device.host}/axis-media" + f"/media.amp{stream_options}" + ), } @property diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 8f3c8b9a8b6..0c132814e39 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -1,10 +1,10 @@ """Axis network device abstraction.""" import asyncio +from asyncio import timeout from types import MappingProxyType from typing import Any -import async_timeout import axis from axis.configuration import Configuration from axis.errors import Unauthorized @@ -253,7 +253,7 @@ async def get_axis_device( ) try: - async with async_timeout.timeout(30): + async with timeout(30): await device.vapix.initialize() return device diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index e511ee72d1b..37be5355800 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -5,8 +5,9 @@ from abc import abstractmethod from axis.models.event import Event, EventTopic from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import DOMAIN as AXIS_DOMAIN from .device import AxisNetworkDevice diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 7c63b9ffafa..dc3d0e5b04b 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -15,8 +15,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index b318c5224df..4005460ecae 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -4,13 +4,8 @@ from __future__ import annotations import json import logging -# pylint: disable-next=import-error, no-name-in-module from azure.servicebus import ServiceBusMessage - -# pylint: disable-next=import-error, no-name-in-module from azure.servicebus.aio import ServiceBusClient, ServiceBusSender - -# pylint: disable-next=import-error, no-name-in-module from azure.servicebus.exceptions import ( MessagingEntityNotFoundError, ServiceBusConnectionError, diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py index c9e51c79b82..fcc648f4001 100644 --- a/homeassistant/components/baf/__init__.py +++ b/homeassistant/components/baf/__init__.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from asyncio import timeout from aiobafi6 import Device, Service from aiobafi6.discovery import PORT -import async_timeout +from aiobafi6.exceptions import DeviceUUIDMismatchError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, Platform @@ -35,8 +36,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: run_future = device.async_run() try: - async with async_timeout.timeout(RUN_TIMEOUT): + async with timeout(RUN_TIMEOUT): await device.async_wait_available() + except DeviceUUIDMismatchError as ex: + raise ConfigEntryNotReady( + f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}" + ) from ex except asyncio.TimeoutError as ex: run_future.cancel() raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 3f37df1b70a..bbae3914533 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -2,12 +2,12 @@ from __future__ import annotations import asyncio +from asyncio import timeout import logging from typing import Any from aiobafi6 import Device, Service from aiobafi6.discovery import PORT -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -27,7 +27,7 @@ async def async_try_connect(ip_address: str) -> Device: device = Device(Service(ip_addresses=[ip_address], port=PORT)) run_future = device.async_run() try: - async with async_timeout.timeout(RUN_TIMEOUT): + async with timeout(RUN_TIMEOUT): await device.async_wait_available() except asyncio.TimeoutError as ex: raise CannotConnect from ex diff --git a/homeassistant/components/baf/entity.py b/homeassistant/components/baf/entity.py index 4aeb287b861..82ea0c16092 100644 --- a/homeassistant/components/baf/entity.py +++ b/homeassistant/components/baf/entity.py @@ -5,8 +5,8 @@ from aiobafi6 import Device from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity class BAFEntity(Entity): diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index 9557005e5eb..ed5eea8796f 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -39,6 +39,8 @@ async def async_setup_entry( class BAFLight(BAFEntity, LightEntity): """Representation of a Big Ass Fans light.""" + _attr_name = None + @callback def _async_update_attrs(self) -> None: """Update attrs from device.""" @@ -63,23 +65,19 @@ class BAFLight(BAFEntity, LightEntity): class BAFFanLight(BAFLight): """Representation of a Big Ass Fans light on a fan.""" - _attr_name = None - - def __init__(self, device: Device) -> None: - """Init a fan light.""" - super().__init__(device) - self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - self._attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS class BAFStandaloneLight(BAFLight): """Representation of a Big Ass Fans light.""" + _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + _attr_color_mode = ColorMode.COLOR_TEMP + def __init__(self, device: Device) -> None: """Init a standalone light.""" super().__init__(device) - self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} - self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_min_mireds = color_temperature_kelvin_to_mired( device.light_warmest_color_temperature ) diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 37fd5cee7c6..497b3638fce 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.8.2"], + "requirements": ["aiobafi6==0.9.0"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index e50c35db477..3b4f7d08fff 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -3,24 +3,24 @@ from __future__ import annotations from pybalboa import EVENT_UPDATE, SpaClient -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN -class BalboaBaseEntity(Entity): +class BalboaEntity(Entity): """Balboa base entity.""" + _attr_should_poll = False + _attr_has_entity_name = True + def __init__(self, client: SpaClient, name: str | None = None) -> None: """Initialize the control.""" mac = client.mac_address model = client.model - - self._attr_should_poll = False self._attr_unique_id = f'{model}-{name}-{mac.replace(":","")[-6:]}' self._attr_name = name - self._attr_has_entity_name = True self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, name=model, @@ -36,10 +36,6 @@ class BalboaBaseEntity(Entity): """Return whether the state is based on actual reading from device.""" return not self._client.available - -class BalboaEntity(BalboaBaseEntity): - """Balboa entity.""" - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self.async_on_remove(self._client.on(EVENT_UPDATE, self.async_write_ha_state)) diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 0bb3a5bbb69..08f2410ee06 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -1,7 +1,7 @@ """Platform for beewi_smartclim integration.""" from __future__ import annotations -from beewi_smartclim import BeewiSmartClimPoller # pylint: disable=import-error +from beewi_smartclim import BeewiSmartClimPoller import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index a1e646c0b32..371bb1aec40 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -12,7 +12,8 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DEFAULT_SETUP_TIMEOUT, DOMAIN, PRODUCT diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index 82c9bb876d7..dbdf034faee 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -7,10 +7,14 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + PERCENTAGE, + UnitOfEnergy, + UnitOfSpeed, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -40,6 +44,22 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), + SensorEntityDescription( + key="powerMeasurement", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="wind_speed", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), ) @@ -75,3 +95,10 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn def native_value(self): """Return the state.""" return self._feature.native_value + + @property + def last_reset(self): + """Return the time when the sensor was last reset, if implemented.""" + native_implementation = getattr(self._feature, "last_reset", None) + + return native_implementation or super().last_reset diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index d7145ebb620..94edc32bc8c 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -32,10 +32,7 @@ async def async_setup_entry( class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity): """Representation of a BleBox switch feature.""" - def __init__(self, feature: blebox_uniapi.switch.Switch) -> None: - """Initialize a BleBox switch feature.""" - super().__init__(feature) - self._attr_device_class = SwitchDeviceClass.SWITCH + _attr_device_class = SwitchDeviceClass.SWITCH @property def is_on(self): diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 75a2644791e..16a8c00d67a 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 1487c6a7b42..1b53a11b1d2 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 9740e427e9c..9f9396c3888 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -9,7 +9,7 @@ from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 3fd1b7d91e5..445a84f838c 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -9,7 +9,7 @@ from blinkpy.auth import Auth, LoginError, TokenRefreshFailed from blinkpy.blinkpy import Blink, BlinkSetupError import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( CONF_PASSWORD, CONF_PIN, @@ -18,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -48,7 +49,7 @@ OPTIONS_FLOW = { } -def validate_input(hass: core.HomeAssistant, auth): +def validate_input(auth: Auth) -> None: """Validate the user input allows us to connect.""" try: auth.startup() @@ -58,7 +59,7 @@ def validate_input(hass: core.HomeAssistant, auth): raise Require2FA -def _send_blink_2fa_pin(auth, pin): +def _send_blink_2fa_pin(auth: Auth, pin: str) -> bool: """Send 2FA pin to blink servers.""" blink = Blink() blink.auth = auth @@ -67,38 +68,34 @@ def _send_blink_2fa_pin(auth, pin): return auth.send_auth_key(blink, pin) -class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Blink config flow.""" VERSION = 3 def __init__(self) -> None: """Initialize the blink flow.""" - self.auth = None + self.auth: Auth | None = None @staticmethod @callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, + config_entry: ConfigEntry, ) -> SchemaOptionsFlowHandler: """Get options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} - data = {CONF_USERNAME: "", CONF_PASSWORD: "", "device_id": DEVICE_ID} if user_input is not None: - data[CONF_USERNAME] = user_input["username"] - data[CONF_PASSWORD] = user_input["password"] - - self.auth = Auth(data, no_prompt=True) - await self.async_set_unique_id(data[CONF_USERNAME]) + self.auth = Auth({**user_input, "device_id": DEVICE_ID}, no_prompt=True) + await self.async_set_unique_id(user_input[CONF_USERNAME]) try: - await self.hass.async_add_executor_job( - validate_input, self.hass, self.auth - ) + await self.hass.async_add_executor_job(validate_input, self.auth) return self._async_finish_flow() except Require2FA: return await self.async_step_2fa() @@ -108,18 +105,20 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - data_schema = { - vol.Required("username"): str, - vol.Required("password"): str, - } - return self.async_show_form( step_id="user", - data_schema=vol.Schema(data_schema), + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), errors=errors, ) - async def async_step_2fa(self, user_input=None): + async def async_step_2fa( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle 2FA step.""" errors = {} if user_input is not None: @@ -142,7 +141,7 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="2fa", data_schema=vol.Schema( - {vol.Optional("pin"): vol.All(str, vol.Length(min=1))} + {vol.Optional(CONF_PIN): vol.All(str, vol.Length(min=1))} ), errors=errors, ) @@ -152,14 +151,15 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user(dict(entry_data)) @callback - def _async_finish_flow(self): + def _async_finish_flow(self) -> FlowResult: """Finish with setup.""" + assert self.auth return self.async_create_entry(title=DOMAIN, data=self.auth.login_attributes) -class Require2FA(exceptions.HomeAssistantError): +class Require2FA(HomeAssistantError): """Error to indicate we require 2FA.""" -class InvalidAuth(exceptions.HomeAssistantError): +class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index c996a90e54d..ceec74a9aa9 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py index 7b59039a89e..b99fdfe0c78 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -49,7 +49,7 @@ def setup_platform( class BloomSkySensor(BinarySensorEntity): """Representation of a single binary sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name + def __init__(self, bs, device, sensor_name): """Initialize a BloomSky binary sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 6cefcdb3346..35c9a40a46a 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -93,7 +93,7 @@ def setup_platform( class BloomSkySensor(SensorEntity): """Representation of a single sensor in a BloomSky device.""" - def __init__(self, bs, device, sensor_name): # pylint: disable=invalid-name + def __init__(self, bs, device, sensor_name): """Initialize a BloomSky sensor.""" self._bloomsky = bs self._device_id = device["DeviceID"] diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index a9bcf5ded1c..1732320c1e9 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -1,9 +1,9 @@ """Websocket API for blueprint.""" from __future__ import annotations +import asyncio from typing import Any, cast -import async_timeout import voluptuous as vol from homeassistant.components import websocket_api @@ -72,7 +72,7 @@ async def ws_import_blueprint( msg: dict[str, Any], ) -> None: """Import a blueprint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): imported_blueprint = await importer.fetch_blueprint_from_url(hass, msg["url"]) if imported_blueprint is None: diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 91984cf6247..eba03963ebc 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from asyncio import CancelledError +from asyncio import CancelledError, timeout from datetime import timedelta from http import HTTPStatus import logging @@ -12,7 +12,6 @@ from urllib import parse import aiohttp from aiohttp.client_exceptions import ClientError from aiohttp.hdrs import CONNECTION, KEEP_ALIVE -import async_timeout import voluptuous as vol import xmltodict @@ -355,7 +354,7 @@ class BluesoundPlayer(MediaPlayerEntity): try: websession = async_get_clientsession(self._hass) - async with async_timeout.timeout(10): + async with timeout(10): response = await websession.get(url) if response.status == HTTPStatus.OK: @@ -396,7 +395,7 @@ class BluesoundPlayer(MediaPlayerEntity): _LOGGER.debug("Calling URL: %s", url) try: - async with async_timeout.timeout(125): + async with timeout(125): response = await self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE} ) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index bf4dbf81f01..2e0e62440ab 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -38,7 +38,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth -from . import models +from . import models, passive_update_processor from .api import ( _get_manager, async_address_present, @@ -125,6 +125,7 @@ async def _async_get_adapter_from_address( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" + await passive_update_processor.async_setup(hass) integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) integration_matcher.async_setup() bluetooth_adapters = get_adapters() diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 6c232e2a42c..be35a9d255d 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -4,11 +4,11 @@ These APIs are the only documented way to interact with the bluetooth integratio """ from __future__ import annotations +import asyncio from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast -import async_timeout from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -152,7 +152,7 @@ async def async_process_advertisements( ) try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): return await done finally: unload() diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index ce778e0309b..bd91c622316 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -637,7 +637,7 @@ class BluetoothManager: else: # We could write out every item in the typed dict here # but that would be a bit inefficient and verbose. - callback_matcher.update(matcher) # type: ignore[typeddict-item] + callback_matcher.update(matcher) callback_matcher[CONNECTABLE] = matcher.get(CONNECTABLE, True) connectable = callback_matcher[CONNECTABLE] diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 67c27f014d1..e1a5ee41324 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -14,11 +14,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.2", - "bleak-retry-connector==3.1.1", + "bleak==0.21.0", + "bleak-retry-connector==3.1.2", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", - "bluetooth-data-tools==1.6.1", - "dbus-fast==1.90.1" + "bluetooth-data-tools==1.11.0", + "dbus-fast==1.94.1" ] } diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 607abaa0168..20b992d06d6 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -2,22 +2,46 @@ from __future__ import annotations import dataclasses +from datetime import timedelta +from functools import cache import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant import config_entries +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_NAME, + CONF_ENTITY_CATEGORY, + EVENT_HOMEASSISTANT_STOP, + EntityCategory, +) +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_platform import async_get_current_platform +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.storage import Store +from homeassistant.util.enum import try_parse_enum from .const import DOMAIN from .update_coordinator import BasePassiveBluetoothCoordinator if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable from homeassistant.helpers.entity_platform import AddEntitiesCallback - from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak + from .models import ( + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + ) + +STORAGE_KEY = "bluetooth.passive_update_processor" +STORAGE_VERSION = 1 +STORAGE_SAVE_INTERVAL = timedelta(minutes=15) +PASSIVE_UPDATE_PROCESSOR = "passive_update_processor" +_T = TypeVar("_T") @dataclasses.dataclass(slots=True, frozen=True) @@ -32,8 +56,67 @@ class PassiveBluetoothEntityKey: key: str device_id: str | None + def to_string(self) -> str: + """Convert the key to a string which can be used as JSON key.""" + return f"{self.key}___{self.device_id or ''}" -_T = TypeVar("_T") + @classmethod + def from_string(cls, key: str) -> PassiveBluetoothEntityKey: + """Convert a string (from JSON) to a key.""" + key, device_id = key.split("___") + return cls(key, device_id or None) + + +@dataclasses.dataclass(slots=True, frozen=False) +class PassiveBluetoothProcessorData: + """Data for the passive bluetooth processor.""" + + coordinators: set[PassiveBluetoothProcessorCoordinator] + all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] + + +class RestoredPassiveBluetoothDataUpdate(TypedDict): + """Restored PassiveBluetoothDataUpdate.""" + + devices: dict[str, DeviceInfo] + entity_descriptions: dict[str, dict[str, Any]] + entity_names: dict[str, str | None] + entity_data: dict[str, Any] + + +# Fields do not change so we can cache the result +# of calling fields() on the dataclass +cached_fields = cache(dataclasses.fields) + + +def deserialize_entity_description( + descriptions_class: type[EntityDescription], data: dict[str, Any] +) -> EntityDescription: + """Deserialize an entity description.""" + result: dict[str, Any] = {} + for field in cached_fields(descriptions_class): # type: ignore[arg-type] + field_name = field.name + # It would be nice if field.type returned the actual + # type instead of a str so we could avoid writing this + # out, but it doesn't. If we end up using this in more + # places we can add a `as_dict` and a `from_dict` + # method to these classes + if field_name == CONF_ENTITY_CATEGORY: + value = try_parse_enum(EntityCategory, data.get(field_name)) + else: + value = data.get(field_name) + result[field_name] = value + return descriptions_class(**result) + + +def serialize_entity_description(description: EntityDescription) -> dict[str, Any]: + """Serialize an entity description.""" + as_dict = dataclasses.asdict(description) + return { + field.name: as_dict[field.name] + for field in cached_fields(type(description)) # type: ignore[arg-type] + if field.default != as_dict.get(field.name) + } @dataclasses.dataclass(slots=True, frozen=True) @@ -41,16 +124,131 @@ class PassiveBluetoothDataUpdate(Generic[_T]): """Generic bluetooth data.""" devices: dict[str | None, DeviceInfo] = dataclasses.field(default_factory=dict) - entity_descriptions: Mapping[ + entity_descriptions: dict[ PassiveBluetoothEntityKey, EntityDescription ] = dataclasses.field(default_factory=dict) - entity_names: Mapping[PassiveBluetoothEntityKey, str | None] = dataclasses.field( + entity_names: dict[PassiveBluetoothEntityKey, str | None] = dataclasses.field( default_factory=dict ) - entity_data: Mapping[PassiveBluetoothEntityKey, _T] = dataclasses.field( + entity_data: dict[PassiveBluetoothEntityKey, _T] = dataclasses.field( default_factory=dict ) + def update(self, new_data: PassiveBluetoothDataUpdate[_T]) -> None: + """Update the data.""" + self.devices.update(new_data.devices) + self.entity_descriptions.update(new_data.entity_descriptions) + self.entity_data.update(new_data.entity_data) + self.entity_names.update(new_data.entity_names) + + def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate: + """Serialize restore data to storage.""" + return { + "devices": { + key or "": device_info for key, device_info in self.devices.items() + }, + "entity_descriptions": { + key.to_string(): serialize_entity_description(description) + for key, description in self.entity_descriptions.items() + }, + "entity_names": { + key.to_string(): name for key, name in self.entity_names.items() + }, + "entity_data": { + key.to_string(): data for key, data in self.entity_data.items() + }, + } + + @callback + def async_set_restore_data( + self, + restore_data: RestoredPassiveBluetoothDataUpdate, + entity_description_class: type[EntityDescription], + ) -> None: + """Set the restored data from storage.""" + self.devices.update( + { + key or None: device_info + for key, device_info in restore_data["devices"].items() + } + ) + self.entity_descriptions.update( + { + PassiveBluetoothEntityKey.from_string( + key + ): deserialize_entity_description(entity_description_class, description) + for key, description in restore_data["entity_descriptions"].items() + if description + } + ) + self.entity_names.update( + { + PassiveBluetoothEntityKey.from_string(key): name + for key, name in restore_data["entity_names"].items() + } + ) + self.entity_data.update( + { + PassiveBluetoothEntityKey.from_string(key): cast(_T, data) + for key, data in restore_data["entity_data"].items() + } + ) + + +def async_register_coordinator_for_restore( + hass: HomeAssistant, coordinator: PassiveBluetoothProcessorCoordinator +) -> CALLBACK_TYPE: + """Register a coordinator to have its processors data restored.""" + data: PassiveBluetoothProcessorData = hass.data[PASSIVE_UPDATE_PROCESSOR] + coordinators = data.coordinators + coordinators.add(coordinator) + if restore_key := coordinator.restore_key: + coordinator.restore_data = data.all_restore_data.setdefault(restore_key, {}) + + @callback + def _unregister_coordinator_for_restore() -> None: + """Unregister a coordinator.""" + coordinators.remove(coordinator) + + return _unregister_coordinator_for_restore + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the passive update processor coordinators.""" + storage: Store[dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + coordinators: set[PassiveBluetoothProcessorCoordinator] = set() + all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] = ( + await storage.async_load() or {} + ) + hass.data[PASSIVE_UPDATE_PROCESSOR] = PassiveBluetoothProcessorData( + coordinators, all_restore_data + ) + + async def _async_save_processor_data(_: Any) -> None: + """Save the processor data.""" + await storage.async_save( + { + coordinator.restore_key: coordinator.async_get_restore_data() + for coordinator in coordinators + if coordinator.restore_key + } + ) + + cancel_interval = async_track_time_interval( + hass, _async_save_processor_data, STORAGE_SAVE_INTERVAL + ) + + async def _async_save_processor_data_at_stop(_event: Event) -> None: + """Save the processor data at shutdown.""" + cancel_interval() + await _async_save_processor_data(None) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_save_processor_data_at_stop + ) + class PassiveBluetoothProcessorCoordinator( Generic[_T], BasePassiveBluetoothCoordinator @@ -79,22 +277,49 @@ class PassiveBluetoothProcessorCoordinator( self._processors: list[PassiveBluetoothDataProcessor] = [] self._update_method = update_method self.last_update_success = True + self.restore_data: dict[str, RestoredPassiveBluetoothDataUpdate] = {} + self.restore_key = None + if config_entry := config_entries.current_entry.get(): + self.restore_key = config_entry.entry_id + self._on_stop.append(async_register_coordinator_for_restore(self.hass, self)) @property def available(self) -> bool: """Return if the device is available.""" return super().available and self.last_update_success + @callback + def async_get_restore_data( + self, + ) -> dict[str, RestoredPassiveBluetoothDataUpdate]: + """Generate the restore data.""" + return { + processor.restore_key: processor.data.async_get_restore_data() + for processor in self._processors + if processor.restore_key + } + @callback def async_register_processor( - self, processor: PassiveBluetoothDataProcessor + self, + processor: PassiveBluetoothDataProcessor, + entity_description_class: type[EntityDescription] | None = None, ) -> Callable[[], None]: """Register a processor that subscribes to updates.""" - processor.coordinator = self + + # entity_description_class will become mandatory + # in the future, but is optional for now to allow + # for a transition period. + processor.async_register_coordinator(self, entity_description_class) @callback def remove_processor() -> None: """Remove a processor.""" + # Save the data before removing the processor + # so if they reload its still there + if restore_key := processor.restore_key: + self.restore_data[restore_key] = processor.data.async_get_restore_data() + self._processors.remove(processor) self._processors.append(processor) @@ -155,7 +380,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): The processor will call the update_method every time the bluetooth device receives a new advertisement data from the coordinator with the data - returned by he update_method of the coordinator. + returned by the update_method of the coordinator. As the size of each advertisement is limited, the update_method should return a PassiveBluetoothDataUpdate object that contains only data that @@ -165,13 +390,23 @@ class PassiveBluetoothDataProcessor(Generic[_T]): """ coordinator: PassiveBluetoothProcessorCoordinator + data: PassiveBluetoothDataUpdate[_T] + entity_names: dict[PassiveBluetoothEntityKey, str | None] + entity_data: dict[PassiveBluetoothEntityKey, _T] + entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] + devices: dict[str | None, DeviceInfo] + restore_key: str | None def __init__( self, update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]], + restore_key: str | None = None, ) -> None: """Initialize the coordinator.""" - self.coordinator: PassiveBluetoothProcessorCoordinator + try: + self.restore_key = restore_key or async_get_current_platform().domain + except RuntimeError: + self.restore_key = None self._listeners: list[ Callable[[PassiveBluetoothDataUpdate[_T] | None], None] ] = [] @@ -180,14 +415,36 @@ class PassiveBluetoothDataProcessor(Generic[_T]): list[Callable[[PassiveBluetoothDataUpdate[_T] | None], None]], ] = {} self.update_method = update_method - self.entity_names: dict[PassiveBluetoothEntityKey, str | None] = {} - self.entity_data: dict[PassiveBluetoothEntityKey, _T] = {} - self.entity_descriptions: dict[ - PassiveBluetoothEntityKey, EntityDescription - ] = {} - self.devices: dict[str | None, DeviceInfo] = {} self.last_update_success = True + @callback + def async_register_coordinator( + self, + coordinator: PassiveBluetoothProcessorCoordinator, + entity_description_class: type[EntityDescription] | None, + ) -> None: + """Register a coordinator.""" + self.coordinator = coordinator + self.data = PassiveBluetoothDataUpdate() + data = self.data + # These attributes to access the data in + # self.data are for backwards compatibility. + self.entity_names = data.entity_names + self.entity_data = data.entity_data + self.entity_descriptions = data.entity_descriptions + self.devices = data.devices + if ( + entity_description_class + and (restore_key := self.restore_key) + and (restore_data := coordinator.restore_data) + and (restored_processor_data := restore_data.get(restore_key)) + ): + data.async_set_restore_data( + restored_processor_data, + entity_description_class, + ) + self.async_update_listeners(data) + @property def available(self) -> bool: """Return if the device is available.""" @@ -202,7 +459,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): def async_add_entities_listener( self, entity_class: type[PassiveBluetoothProcessorEntity], - async_add_entites: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> Callable[[], None]: """Add a listener for new entities.""" created: set[PassiveBluetoothEntityKey] = set() @@ -220,7 +477,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): entities.append(entity_class(self, entity_key, description)) created.add(entity_key) if entities: - async_add_entites(entities) + async_add_entities(entities) return self.async_add_listener(_async_add_or_update_entities) @@ -296,10 +553,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): "Processing %s data recovered", self.coordinator.name ) - self.devices.update(new_data.devices) - self.entity_descriptions.update(new_data.entity_descriptions) - self.entity_data.update(new_data.entity_data) - self.entity_names.update(new_data.entity_names) + self.data.update(new_data) self.async_update_listeners(new_data) diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 35efbdf3cbe..eb3ce11b644 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -8,7 +8,6 @@ import logging import platform from typing import Any -import async_timeout import bleak from bleak import BleakError from bleak.assigned_numbers import AdvertisementDataType @@ -220,7 +219,7 @@ class HaScanner(BaseHaScanner): START_ATTEMPTS, ) try: - async with async_timeout.timeout(START_TIMEOUT): + async with asyncio.timeout(START_TIMEOUT): await self.scanner.start() # type: ignore[no-untyped-call] except InvalidMessageError as ex: _LOGGER.debug( @@ -350,11 +349,10 @@ class HaScanner(BaseHaScanner): try: await self._async_start() except ScannerStartError as ex: - _LOGGER.error( + _LOGGER.exception( "%s: Failed to restart Bluetooth scanner: %s", self.name, ex, - exc_info=True, ) async def _async_reset_adapter(self) -> None: diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 0c41b58c63d..9c38bf2f520 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -6,16 +6,14 @@ import logging from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from . import ( - BluetoothCallbackMatcher, - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, +from .api import ( async_address_present, async_last_service_info, async_register_callback, async_track_unavailable, ) +from .match import BluetoothCallbackMatcher +from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): @@ -37,11 +35,11 @@ class BasePassiveBluetoothCoordinator(ABC): self.logger = logger self.address = address self.connectable = connectable - self._cancel_track_unavailable: CALLBACK_TYPE | None = None - self._cancel_bluetooth_advertisements: CALLBACK_TYPE | None = None + self._on_stop: list[CALLBACK_TYPE] = [] self.mode = mode self._last_unavailable_time = 0.0 self._last_name = address + self._available = async_address_present(hass, address, connectable) @callback def async_start(self) -> CALLBACK_TYPE: @@ -88,32 +86,46 @@ class BasePassiveBluetoothCoordinator(ABC): @property def available(self) -> bool: """Return if the device is available.""" - return async_address_present(self.hass, self.address, self.connectable) + return self._available + + @callback + def _async_handle_bluetooth_event_internal( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a bluetooth event.""" + self._available = True + self._async_handle_bluetooth_event(service_info, change) @callback def _async_start(self) -> None: """Start the callbacks.""" - self._cancel_bluetooth_advertisements = async_register_callback( - self.hass, - self._async_handle_bluetooth_event, - BluetoothCallbackMatcher( - address=self.address, connectable=self.connectable - ), - self.mode, + self._on_stop.append( + async_register_callback( + self.hass, + self._async_handle_bluetooth_event_internal, + BluetoothCallbackMatcher( + address=self.address, connectable=self.connectable + ), + self.mode, + ) ) - self._cancel_track_unavailable = async_track_unavailable( - self.hass, self._async_handle_unavailable, self.address, self.connectable + self._on_stop.append( + async_track_unavailable( + self.hass, + self._async_handle_unavailable, + self.address, + self.connectable, + ) ) @callback def _async_stop(self) -> None: """Stop the callbacks.""" - if self._cancel_bluetooth_advertisements is not None: - self._cancel_bluetooth_advertisements() - self._cancel_bluetooth_advertisements = None - if self._cancel_track_unavailable is not None: - self._cancel_track_unavailable() - self._cancel_track_unavailable = None + for unsub in self._on_stop: + unsub() + self._on_stop.clear() @callback def _async_handle_unavailable( @@ -122,3 +134,4 @@ class BasePassiveBluetoothCoordinator(ABC): """Handle the device going unavailable.""" self._last_unavailable_time = service_info.time self._last_name = service_info.name + self._available = False diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 2ae036080f8..97f253f8825 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -120,15 +120,17 @@ class HaBleakScannerWrapper(BaseBleakScanner): def register_detection_callback( self, callback: AdvertisementDataCallback | None - ) -> None: + ) -> Callable[[], None]: """Register a detection callback. The callback is called when a device is discovered or has a property changed. - This method takes the callback and registers it with the long running sscanner. + This method takes the callback and registers it with the long running scanner. """ self._advertisement_data_callback = callback self._setup_detection_callback() + assert self._detection_cancel is not None + return self._detection_cancel def _setup_detection_callback(self) -> None: """Set up the detection callback.""" @@ -199,7 +201,7 @@ class HaBleakClientWrapper(BleakClient): when an integration does this. """ - def __init__( # pylint: disable=super-init-not-called, keyword-arg-before-vararg + def __init__( # pylint: disable=super-init-not-called self, address_or_ble_device: str | BLEDevice, disconnected_callback: Callable[[BleakClient], None] | None = None, diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index f4fc6a8df08..4bfbe72d8b5 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta import logging from typing import Final -import bluetooth # pylint: disable=import-error +import bluetooth from bt_proximity import BluetoothRSSI import voluptuous as vol diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 27f2d99cd2d..d5a213256c3 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platf from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery, entity_registry as er import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index c3be7ae189b..d3711a8f2e6 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -37,11 +37,14 @@ ALLOWED_CONDITION_BASED_SERVICE_KEYS = { "TIRE_WEAR_REAR", "VEHICLE_CHECK", "VEHICLE_TUV", - "WASHING_FLUID", } LOGGED_CONDITION_BASED_SERVICE_WARNINGS: set[str] = set() -ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = {"ENGINE_OIL", "TIRE_PRESSURE"} +ALLOWED_CHECK_CONTROL_MESSAGE_KEYS = { + "ENGINE_OIL", + "TIRE_PRESSURE", + "WASHING_FLUID", +} LOGGED_CHECK_CONTROL_MESSAGE_WARNINGS: set[str] = set() diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 6edb1a3f2ac..c3f066610a9 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -26,14 +26,17 @@ _LOGGER = logging.getLogger(__name__) @dataclass -class BMWButtonEntityDescription(ButtonEntityDescription): +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]] + + +@dataclass +class BMWButtonEntityDescription(ButtonEntityDescription, BMWRequiredKeysMixin): """Class describing BMW button entities.""" enabled_when_read_only: bool = False - remote_function: Callable[ - [MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus] - ] | None = None - account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None is_available: Callable[[MyBMWVehicle], bool] = lambda _: True @@ -69,13 +72,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( icon="mdi:crosshairs-question", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), ), - BMWButtonEntityDescription( - key="refresh", - translation_key="refresh", - icon="mdi:refresh", - account_function=lambda coordinator: coordinator.async_request_refresh(), - enabled_when_read_only=True, - ), ) @@ -120,22 +116,9 @@ class BMWButton(BMWBaseEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - if self.entity_description.remote_function: - try: - await self.entity_description.remote_function(self.vehicle) - except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex - elif self.entity_description.account_function: - _LOGGER.warning( - "The 'Refresh from cloud' button is deprecated. Use the" - " 'homeassistant.update_entity' service with any BMW entity for a full" - " reload. See" - " https://www.home-assistant.io/integrations/bmw_connected_drive/#update-the-state--refresh-from-api" - " for details" - ) - try: - await self.entity_description.account_function(self.coordinator) - except MyBMWAPIError as ex: - raise HomeAssistantError(ex) from ex + try: + await self.entity_description.remote_function(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 4a586aab373..2634c6069c9 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -58,7 +58,10 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): try: await self.account.get_vehicles() except MyBMWAuthError as err: - # Clear refresh token and trigger reauth + # Allow one retry interval before raising AuthFailed to avoid flaky API issues + if self.last_update_success: + raise UpdateFailed(err) from err + # Clear refresh token and trigger reauth if previous update failed as well self._update_config_entry_refresh_token(None) raise ConfigEntryAuthFailed(err) from err except (MyBMWAPIError, RequestError) as err: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index ff2804a8c04..0a9e9cac5af 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.13.9"] + "requirements": ["bimmer-connected==0.14.0"] } diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index af73417b1a9..69abd97ddfe 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -66,9 +66,6 @@ }, "find_vehicle": { "name": "Find vehicle" - }, - "refresh": { - "name": "Refresh from cloud" } }, "lock": { diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 36af3974482..3b3ace98950 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -18,7 +18,8 @@ from homeassistant.const import ( ATTR_VIA_DEVICE, ) from homeassistant.core import CALLBACK_TYPE, callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later from .const import DOMAIN diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 1512cf7b2b4..bc6235cb219 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -153,7 +153,7 @@ class BondFan(BondEntity, FanEntity): except ClientResponseError as ex: raise HomeAssistantError( "The bond API returned an error calling set_power_state_belief for" - f" {self.entity_id}. Code: {ex.code} Message: {ex.message}" + f" {self.entity_id}. Code: {ex.status} Message: {ex.message}" ) from ex async def async_set_speed_belief(self, speed: int) -> None: diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 2380321cc4c..c5816153c8d 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -138,7 +138,7 @@ class BondBaseLight(BondEntity, LightEntity): except ClientResponseError as ex: raise HomeAssistantError( "The bond API returned an error calling set_brightness_belief for" - f" {self.entity_id}. Code: {ex.code} Message: {ex.message}" + f" {self.entity_id}. Code: {ex.status} Message: {ex.message}" ) from ex async def async_set_power_belief(self, power_state: bool) -> None: @@ -150,7 +150,7 @@ class BondBaseLight(BondEntity, LightEntity): except ClientResponseError as ex: raise HomeAssistantError( "The bond API returned an error calling set_light_state_belief for" - f" {self.entity_id}. Code: {ex.code} Message: {ex.message}" + f" {self.entity_id}. Code: {ex.status} Message: {ex.message}" ) from ex @@ -313,7 +313,7 @@ class BondFireplace(BondEntity, LightEntity): except ClientResponseError as ex: raise HomeAssistantError( "The bond API returned an error calling set_brightness_belief for" - f" {self.entity_id}. Code: {ex.code} Message: {ex.message}" + f" {self.entity_id}. Code: {ex.status} Message: {ex.message}" ) from ex async def async_set_power_belief(self, power_state: bool) -> None: @@ -325,5 +325,5 @@ class BondFireplace(BondEntity, LightEntity): except ClientResponseError as ex: raise HomeAssistantError( "The bond API returned an error calling set_power_state_belief for" - f" {self.entity_id}. Code: {ex.code} Message: {ex.message}" + f" {self.entity_id}. Code: {ex.status} Message: {ex.message}" ) from ex diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index c0ff6368e5a..887532defd1 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -65,5 +65,5 @@ class BondSwitch(BondEntity, SwitchEntity): except ClientResponseError as ex: raise HomeAssistantError( "The bond API returned an error calling set_power_state_belief for" - f" {self.entity_id}. Code: {ex.code} Message: {ex.message}" + f" {self.entity_id}. Code: {ex.status} Message: {ex.message}" ) from ex diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index 25ab320a4c4..c9969fcf415 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -25,7 +25,9 @@ async def async_setup_entry( entities: list[BinarySensorEntity] = [] session: SHCSession = hass.data[DOMAIN][config_entry.entry_id][DATA_SESSION] - for binary_sensor in session.device_helper.shutter_contacts: + for binary_sensor in ( + session.device_helper.shutter_contacts + session.device_helper.shutter_contacts2 + ): entities.append( ShutterContactSensor( device=binary_sensor, @@ -37,6 +39,7 @@ async def async_setup_entry( for binary_sensor in ( session.device_helper.motion_detectors + session.device_helper.shutter_contacts + + session.device_helper.shutter_contacts2 + session.device_helper.smoke_detectors + session.device_helper.thermostats + session.device_helper.twinguards @@ -59,6 +62,8 @@ async def async_setup_entry( class ShutterContactSensor(SHCEntity, BinarySensorEntity): """Representation of an SHC shutter contact sensor.""" + _attr_name = None + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC shutter contact sensor..""" super().__init__(device, parent_id, entry_id) @@ -86,7 +91,6 @@ class BatterySensor(SHCEntity, BinarySensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC battery reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Battery" self._attr_unique_id = f"{device.serial}_battery" @property diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 3f1a9eccb93..8b2a2f65c12 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -42,6 +42,7 @@ async def async_setup_entry( class ShutterControlCover(SHCEntity, CoverEntity): """Representation of a SHC shutter control device.""" + _attr_name = None _attr_device_class = CoverDeviceClass.SHUTTER _attr_supported_features = ( CoverEntityFeature.OPEN diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index 3cf92a8adcc..8c26d2e6d5a 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -4,8 +4,8 @@ from __future__ import annotations from boschshcpy import SHCDevice, SHCIntrusionSystem from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import async_get as get_dev_reg -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo, async_get as get_dev_reg +from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -24,6 +24,7 @@ class SHCBaseEntity(Entity): """Base representation of a SHC entity.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, device: SHCDevice | SHCIntrusionSystem, parent_id: str, entry_id: str @@ -31,7 +32,6 @@ class SHCBaseEntity(Entity): """Initialize the generic SHC device.""" self._device = device self._entry_id = entry_id - self._attr_name = device.name async def async_added_to_hass(self) -> None: """Subscribe to SHC events.""" diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 73307d9ea0a..df216ed0ff2 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -170,7 +170,6 @@ class TemperatureSensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC temperature reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Temperature" self._attr_unique_id = f"{device.serial}_temperature" @property @@ -188,7 +187,6 @@ class HumiditySensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC humidity reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Humidity" self._attr_unique_id = f"{device.serial}_humidity" @property @@ -200,13 +198,13 @@ class HumiditySensor(SHCEntity, SensorEntity): class PuritySensor(SHCEntity, SensorEntity): """Representation of an SHC purity reporting sensor.""" + _attr_translation_key = "purity" _attr_icon = "mdi:molecule-co2" _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC purity reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Purity" self._attr_unique_id = f"{device.serial}_purity" @property @@ -218,10 +216,11 @@ class PuritySensor(SHCEntity, SensorEntity): class AirQualitySensor(SHCEntity, SensorEntity): """Representation of an SHC airquality reporting sensor.""" + _attr_translation_key = "air_quality" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC airquality reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Air Quality" self._attr_unique_id = f"{device.serial}_airquality" @property @@ -240,10 +239,11 @@ class AirQualitySensor(SHCEntity, SensorEntity): class TemperatureRatingSensor(SHCEntity, SensorEntity): """Representation of an SHC temperature rating sensor.""" + _attr_translation_key = "temperature_rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC temperature rating sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Temperature Rating" self._attr_unique_id = f"{device.serial}_temperature_rating" @property @@ -255,12 +255,12 @@ class TemperatureRatingSensor(SHCEntity, SensorEntity): class CommunicationQualitySensor(SHCEntity, SensorEntity): """Representation of an SHC communication quality reporting sensor.""" + _attr_translation_key = "communication_quality" _attr_icon = "mdi:wifi" def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC communication quality reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Communication Quality" self._attr_unique_id = f"{device.serial}_communication_quality" @property @@ -272,10 +272,11 @@ class CommunicationQualitySensor(SHCEntity, SensorEntity): class HumidityRatingSensor(SHCEntity, SensorEntity): """Representation of an SHC humidity rating sensor.""" + _attr_translation_key = "humidity_rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC humidity rating sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Humidity Rating" self._attr_unique_id = f"{device.serial}_humidity_rating" @property @@ -287,10 +288,11 @@ class HumidityRatingSensor(SHCEntity, SensorEntity): class PurityRatingSensor(SHCEntity, SensorEntity): """Representation of an SHC purity rating sensor.""" + _attr_translation_key = "purity_rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC purity rating sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Purity Rating" self._attr_unique_id = f"{device.serial}_purity_rating" @property @@ -308,7 +310,6 @@ class PowerSensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC power reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Power" self._attr_unique_id = f"{device.serial}_power" @property @@ -327,7 +328,6 @@ class EnergySensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC energy reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{self._device.name} Energy" self._attr_unique_id = f"{self._device.serial}_energy" @property @@ -340,13 +340,13 @@ class ValveTappetSensor(SHCEntity, SensorEntity): """Representation of an SHC valve tappet reporting sensor.""" _attr_icon = "mdi:gauge" + _attr_translation_key = "valvetappet" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC valve tappet reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Valvetappet" self._attr_unique_id = f"{device.serial}_valvetappet" @property diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 2b5720f0849..67462b78bec 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -36,5 +36,35 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "flow_title": "Bosch SHC: {name}" + }, + "entity": { + "sensor": { + "purity_rating": { + "name": "Purity rating" + }, + "purity": { + "name": "Purity" + }, + "valvetappet": { + "name": "Valvetappet" + }, + "air_quality": { + "name": "Air quality" + }, + "temperature_rating": { + "name": "Temperature rating" + }, + "humidity_rating": { + "name": "Humidity rating" + }, + "communication_quality": { + "name": "Communication quality" + } + }, + "switch": { + "routing": { + "name": "Routing" + } + } } } diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 3b3b6e2ffd4..25af0628780 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -200,12 +200,12 @@ class SHCRoutingSwitch(SHCEntity, SwitchEntity): """Representation of a SHC routing switch.""" _attr_icon = "mdi:wifi" + _attr_translation_key = "routing" _attr_entity_category = EntityCategory.CONFIG def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC communication quality reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Routing" self._attr_unique_id = f"{device.serial}_routing" @property diff --git a/homeassistant/components/braviatv/entity.py b/homeassistant/components/braviatv/entity.py index a947513e713..0f941d05e75 100644 --- a/homeassistant/components/braviatv/entity.py +++ b/homeassistant/components/braviatv/entity.py @@ -1,5 +1,5 @@ """A entity class for Bravia TV integration.""" -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BraviaTVCoordinator diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py index 2c7a05a7e70..ffd0b46e0bf 100644 --- a/homeassistant/components/broadlink/entity.py +++ b/homeassistant/components/broadlink/entity.py @@ -1,7 +1,8 @@ """Broadlink entities.""" from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 5f05caf0fc1..27ac97a27dc 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -1,10 +1,10 @@ """The Brother component.""" from __future__ import annotations +from asyncio import timeout from datetime import timedelta import logging -import async_timeout from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError from homeassistant.config_entries import ConfigEntry @@ -79,7 +79,7 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): async def _async_update_data(self) -> BrotherSensors: """Update data via library.""" try: - async with async_timeout.timeout(20): + async with timeout(20): data = await self.brother.async_update() except (ConnectionError, SnmpError, UnsupportedModelError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 191bfff249c..4ea6f7abbad 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index add558ff48b..7d24ebd50b7 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -15,8 +15,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py index b01f04fa140..9dc3e1fe66a 100644 --- a/homeassistant/components/browser/__init__.py +++ b/homeassistant/components/browser/__init__.py @@ -16,8 +16,7 @@ SERVICE_BROWSE_URL = "browse_url" SERVICE_BROWSE_URL_SCHEMA = vol.Schema( { - # pylint: disable-next=no-value-for-parameter - vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url() + vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url(), } ) diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py index 979b3f5b005..660c43f1004 100644 --- a/homeassistant/components/brunt/__init__.py +++ b/homeassistant/components/brunt/__init__.py @@ -1,10 +1,10 @@ """The brunt component.""" from __future__ import annotations +from asyncio import timeout import logging from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError -import async_timeout from brunt import BruntClientAsync, Thing from homeassistant.config_entries import ConfigEntry @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account. """ try: - async with async_timeout.timeout(10): + async with timeout(10): things = await bapi.async_get_things(force=True) return {thing.serial: thing for thing in things} except ServerDisconnectedError as err: diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 9f916e5751f..1bde667a237 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0ef3ed159a6..224cb479dda 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_PASSKEY, DOMAIN, LOGGER, SCAN_INTERVAL +from .const import CONF_PASSKEY, DOMAIN +from .coordinator import BSBLanUpdateCoordinator PLATFORMS = [Platform.CLIMATE] @@ -44,13 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, ) - coordinator: DataUpdateCoordinator[State] = DataUpdateCoordinator( - hass, - LOGGER, - name=f"{DOMAIN}_{entry.data[CONF_HOST]}", - update_interval=SCAN_INTERVAL, - update_method=bsblan.state, - ) + coordinator = BSBLanUpdateCoordinator(hass, entry, bsblan) await coordinator.async_config_entry_first_refresh() device = await bsblan.device() diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py new file mode 100644 index 00000000000..9344500a118 --- /dev/null +++ b/homeassistant/components/bsblan/coordinator.py @@ -0,0 +1,54 @@ +"""DataUpdateCoordinator for the BSB-Lan integration.""" +from __future__ import annotations + +from datetime import timedelta +from random import randint + +from bsblan import BSBLAN, BSBLANConnectionError +from bsblan.models import State + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, SCAN_INTERVAL + + +class BSBLanUpdateCoordinator(DataUpdateCoordinator[State]): + """The BSB-Lan update coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: BSBLAN, + ) -> None: + """Initialize the BSB-Lan coordinator.""" + + self.client = client + + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}", + # use the default scan interval and add a random number of seconds to avoid timeouts when + # the BSB-Lan device is already/still busy retrieving data, e.g. for MQTT or internal logging. + update_interval=SCAN_INTERVAL + timedelta(seconds=randint(1, 8)), + ) + + async def _async_update_data(self) -> State: + """Get state from BSB-Lan device.""" + + # use the default scan interval and add a random number of seconds to avoid timeouts when + # the BSB-Lan device is already/still busy retrieving data, e.g. for MQTT or internal logging. + self.update_interval = SCAN_INTERVAL + timedelta(seconds=randint(1, 8)) + + try: + return await self.client.state() + except BSBLANConnectionError as err: + raise UpdateFailed( + f"Error while establishing connection with BSB-Lan device at {self.config_entry.data[CONF_HOST]}" + ) from err diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index c9b2a2ae9ae..d45749a9a86 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -5,8 +5,8 @@ from bsblan import BSBLAN, Device, Info, StaticState from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST -from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity from .const import DOMAIN diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 5abb888513d..59d52c3ae00 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.11"] + "requirements": ["python-bsblan==0.5.16"] } diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 3e2e17a9a21..751c8f74bf9 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -20,6 +20,7 @@ from .const import ( BTHOME_BLE_EVENT, CONF_BINDKEY, CONF_DISCOVERED_EVENT_CLASSES, + CONF_SLEEPY_DEVICE, DOMAIN, BTHomeBleEvent, ) @@ -43,6 +44,11 @@ def process_service_info( entry.entry_id ] discovered_device_classes = coordinator.discovered_device_classes + if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device: + hass.config_entries.async_update_entry( + entry, + data=entry.data | {CONF_SLEEPY_DEVICE: data.sleepy_device}, + ) if update.events: address = service_info.device.address for device_key, event in update.events.items(): @@ -113,6 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) ), connectable=False, + entry=entry, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py index d9d24e95007..02a226d1f7c 100644 --- a/homeassistant/components/bthome/binary_sensor.py +++ b/homeassistant/components/bthome/binary_sensor.py @@ -186,7 +186,9 @@ async def async_setup_entry( BTHomeBluetoothBinarySensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, BinarySensorEntityDescription) + ) class BTHomeBluetoothBinarySensorEntity( @@ -203,7 +205,4 @@ class BTHomeBluetoothBinarySensorEntity( @property def available(self) -> bool: """Return True if entity is available.""" - coordinator: BTHomePassiveBluetoothProcessorCoordinator = ( - self.processor.coordinator - ) - return coordinator.device_data.sleepy_device or super().available + return self.processor.coordinator.sleepy_device or super().available diff --git a/homeassistant/components/bthome/const.py b/homeassistant/components/bthome/const.py index 75a8ab4fc86..780833bf92e 100644 --- a/homeassistant/components/bthome/const.py +++ b/homeassistant/components/bthome/const.py @@ -7,6 +7,7 @@ DOMAIN = "bthome" CONF_BINDKEY: Final = "bindkey" CONF_DISCOVERED_EVENT_CLASSES: Final = "known_events" +CONF_SLEEPY_DEVICE: Final = "sleepy_device" CONF_SUBTYPE: Final = "subtype" EVENT_TYPE: Final = "event_type" diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index dafa932a73e..bb743be7c7f 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -13,8 +13,11 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothProcessorCoordinator, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .const import CONF_SLEEPY_DEVICE + class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordinator): """Define a BTHome Bluetooth Passive Update Processor Coordinator.""" @@ -28,12 +31,19 @@ class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordi update_method: Callable[[BluetoothServiceInfoBleak], Any], device_data: BTHomeBluetoothDeviceData, discovered_device_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.device_data = device_data + self.entry = entry + + @property + def sleepy_device(self) -> bool: + """Return True if the device is a sleepy device.""" + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) class BTHomePassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py index 4111777375d..158253ec8a7 100644 --- a/homeassistant/components/bthome/logbook.py +++ b/homeassistant/components/bthome/logbook.py @@ -8,11 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import async_get from homeassistant.helpers.typing import EventType -from .const import ( - BTHOME_BLE_EVENT, - DOMAIN, - BTHomeBleEvent, -) +from .const import BTHOME_BLE_EVENT, DOMAIN, BTHomeBleEvent @callback diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 418c7b8e3e3..7f53a5b5f06 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.0.0"] + "requirements": ["bthome-ble==3.1.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index fc8673e801b..06f205246c8 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -2,6 +2,9 @@ from __future__ import annotations from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdate, Units +from bthome_ble.const import ( + ExtendedSensorDeviceClass as BTHomeExtendedSensorDeviceClass, +) from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( @@ -66,7 +69,7 @@ SENSOR_DESCRIPTIONS = { ), # Count (-) (BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.COUNT}", + key=str(BTHomeSensorDeviceClass.COUNT), state_class=SensorStateClass.MEASUREMENT, ), # CO2 (parts per million) @@ -186,7 +189,7 @@ SENSOR_DESCRIPTIONS = { ), # Packet Id (-) (BTHomeSensorDeviceClass.PACKET_ID, None): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.PACKET_ID}", + key=str(BTHomeSensorDeviceClass.PACKET_ID), state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -260,12 +263,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + # Text (-) + (BTHomeExtendedSensorDeviceClass.TEXT, None): SensorEntityDescription( + key=str(BTHomeExtendedSensorDeviceClass.TEXT), + ), # Timestamp (datetime object) ( BTHomeSensorDeviceClass.TIMESTAMP, None, ): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.TIMESTAMP}", + key=str(BTHomeSensorDeviceClass.TIMESTAMP), device_class=SensorDeviceClass.TIMESTAMP, state_class=SensorStateClass.MEASUREMENT, ), @@ -274,7 +281,7 @@ SENSOR_DESCRIPTIONS = { BTHomeSensorDeviceClass.UV_INDEX, None, ): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.UV_INDEX}", + key=str(BTHomeSensorDeviceClass.UV_INDEX), state_class=SensorStateClass.MEASUREMENT, ), # Volatile organic Compounds (VOC) (µg/m3) @@ -383,7 +390,9 @@ async def async_setup_entry( BTHomeBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class BTHomeBluetoothSensorEntity( @@ -400,7 +409,4 @@ class BTHomeBluetoothSensorEntity( @property def available(self) -> bool: """Return True if entity is available.""" - coordinator: BTHomePassiveBluetoothProcessorCoordinator = ( - self.processor.coordinator - ) - return coordinator.device_data.sleepy_device or super().available + return self.processor.coordinator.sleepy_device or super().available diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 8111f63c923..718812c5c73 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -14,10 +14,10 @@ CONF_TIMEFRAME = "timeframe" SUPPORTED_COUNTRY_CODES = ["NL", "BE"] DEFAULT_COUNTRY = "NL" -"""Schedule next call after (minutes).""" SCHEDULE_OK = 10 -"""When an error occurred, new call after (minutes).""" +"""Schedule next call after (minutes).""" SCHEDULE_NOK = 2 +"""When an error occurred, new call after (minutes).""" STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"] diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index b5c6e9cf32c..fe3ce3164fe 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -129,14 +129,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="humidity", - translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, icon="mdi:water-percent", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temperature", - translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -150,7 +149,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="windspeed", - translation_key="windspeed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -174,7 +172,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="pressure", - translation_key="pressure", + device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, @@ -194,14 +192,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="precipitation", - translation_key="precipitation", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="irradiance", - translation_key="irradiance", device_class=SensorDeviceClass.IRRADIANCE, native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, state_class=SensorStateClass.MEASUREMENT, @@ -718,17 +714,18 @@ async def async_setup_entry( timeframe, ) + # create weather entities: entities = [ BrSensor(config.get(CONF_NAME, "Buienradar"), coordinates, description) for description in SENSOR_TYPES ] - async_add_entities(entities) - + # create weather data: data = BrData(hass, coordinates, timeframe, entities) - # schedule the first update in 1 minute from now: - await data.schedule_update(1) hass.data[DOMAIN][entry.entry_id][Platform.SENSOR] = data + await data.async_update() + + async_add_entities(entities) class BrSensor(SensorEntity): @@ -757,9 +754,9 @@ class BrSensor(SensorEntity): self._timeframe = None @callback - def data_updated(self, data): + def data_updated(self, data: BrData): """Update data.""" - if self.hass and self._load_data(data): + if self._load_data(data.data) and self.hass: self.async_write_ha_state() @callback diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index f254f7602f8..2141f420167 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -84,18 +84,9 @@ "feeltemperature": { "name": "Feel temperature" }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, "groundtemperature": { "name": "Ground temperature" }, - "windspeed": { - "name": "[%key:component::sensor::entity_component::wind_speed::name%]" - }, "windforce": { "name": "Wind force" }, @@ -105,21 +96,12 @@ "windazimuth": { "name": "Wind direction azimuth" }, - "pressure": { - "name": "[%key:component::sensor::entity_component::pressure::name%]" - }, "visibility": { "name": "[%key:component::weather::entity_component::_::state_attributes::visibility::name%]" }, "windgust": { "name": "Wind gust" }, - "precipitation": { - "name": "[%key:component::sensor::entity_component::precipitation::name%]" - }, - "irradiance": { - "name": "[%key:component::sensor::entity_component::irradiance::name%]" - }, "precipitation_forecast_average": { "name": "Precipitation forecast average" }, diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 54f3732afe4..63e0004dc43 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -1,11 +1,11 @@ """Shared utilities for different supported platforms.""" import asyncio +from asyncio import timeout from datetime import datetime, timedelta from http import HTTPStatus import logging import aiohttp -import async_timeout from buienradar.buienradar import parse_data from buienradar.constants import ( ATTRIBUTION, @@ -27,7 +27,7 @@ from buienradar.constants import ( from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import CALLBACK_TYPE +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -75,9 +75,10 @@ class BrData: # Update all devices for dev in self.devices: - dev.data_updated(self.data) + dev.data_updated(self) - async def schedule_update(self, minute=1): + @callback + def async_schedule_update(self, minute=1): """Schedule an update after minute minutes.""" _LOGGER.debug("Scheduling next update in %s minutes", minute) nxt = dt_util.utcnow() + timedelta(minutes=minute) @@ -92,7 +93,7 @@ class BrData: resp = None try: websession = async_get_clientsession(self.hass) - async with async_timeout.timeout(10): + async with timeout(10): resp = await websession.get(url) result[STATUS_CODE] = resp.status @@ -108,9 +109,9 @@ class BrData: return result finally: if resp is not None: - await resp.release() + resp.release() - async def async_update(self, *_): + async def _async_update(self): """Update the data from buienradar.""" content = await self.get_data(JSON_FEED_URL) @@ -123,9 +124,7 @@ class BrData: content.get(MESSAGE), content.get(STATUS_CODE), ) - # schedule new call - await self.schedule_update(SCHEDULE_NOK) - return + return None self.load_error_count = 0 # rounding coordinates prevents unnecessary redirects/calls @@ -143,9 +142,7 @@ class BrData: raincontent.get(MESSAGE), raincontent.get(STATUS_CODE), ) - # schedule new call - await self.schedule_update(SCHEDULE_NOK) - return + return None self.rain_error_count = 0 result = parse_data( @@ -164,12 +161,21 @@ class BrData: "Unable to parse data from Buienradar. (Msg: %s)", result.get(MESSAGE), ) - await self.schedule_update(SCHEDULE_NOK) + return None + + return result[DATA] + + async def async_update(self, *_): + """Update the data from buienradar and schedule the next update.""" + data = await self._async_update() + + if data is None: + self.async_schedule_update(SCHEDULE_NOK) return - self.data = result.get(DATA) + self.data = data await self.update_devices() - await self.schedule_update(SCHEDULE_OK) + self.async_schedule_update(SCHEDULE_OK) @property def attribution(self): diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index c2a276eed1c..de00faadd64 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -34,7 +34,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -48,7 +50,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback # Reuse data and API logic from the sensor implementation @@ -82,6 +84,11 @@ CONDITION_CLASSES = { ATTR_CONDITION_WINDY_VARIANT: (), ATTR_CONDITION_EXCEPTIONAL: (), } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} async def async_setup_entry( @@ -99,24 +106,16 @@ async def async_setup_entry( coordinates = {CONF_LATITUDE: float(latitude), CONF_LONGITUDE: float(longitude)} - # create weather data: - data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None) - hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data - # create weather device: + # create weather entity: _LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates) + entities = [BrWeather(config, coordinates)] - # create condition helper - if DATA_CONDITION not in hass.data[DOMAIN]: - cond_keys = [str(chr(x)) for x in range(97, 123)] - hass.data[DOMAIN][DATA_CONDITION] = dict.fromkeys(cond_keys) - for cond, condlst in CONDITION_CLASSES.items(): - for condi in condlst: - hass.data[DOMAIN][DATA_CONDITION][condi] = cond + # create weather data: + data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, entities) + hass.data[DOMAIN][entry.entry_id][Platform.WEATHER] = data + await data.async_update() - async_add_entities([BrWeather(data, config, coordinates)]) - - # schedule the first update in 1 minute from now: - await data.schedule_update(1) + async_add_entities(entities) class BrWeather(WeatherEntity): @@ -127,81 +126,62 @@ class BrWeather(WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.METERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_should_poll = False + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY - def __init__(self, data, config, coordinates): + def __init__(self, config, coordinates): """Initialize the platform with a data instance and station name.""" self._stationname = config.get(CONF_NAME, "Buienradar") - self._attr_name = ( - self._stationname or f"BR {data.stationname or '(unknown station)'}" - ) - self._data = data + self._attr_name = self._stationname or f"BR {'(unknown station)'}" self._attr_unique_id = "{:2.6f}{:2.6f}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE] ) - @property - def attribution(self): - """Return the attribution.""" - return self._data.attribution + @callback + def data_updated(self, data: BrData) -> None: + """Update data.""" + self._attr_attribution = data.attribution + self._attr_condition = self._calc_condition(data) + self._attr_forecast = self._calc_forecast(data) + self._attr_humidity = data.humidity + self._attr_name = ( + self._stationname or f"BR {data.stationname or '(unknown station)'}" + ) + self._attr_native_pressure = data.pressure + self._attr_native_temperature = data.temperature + self._attr_native_visibility = data.visibility + self._attr_native_wind_speed = data.wind_speed + self._attr_wind_bearing = data.wind_bearing - @property - def condition(self): + if not self.hass: + return + self.async_write_ha_state() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("daily",)) + ) + + def _calc_condition(self, data: BrData): """Return the current condition.""" - if ( - self._data - and self._data.condition - and (ccode := self._data.condition.get(CONDCODE)) - and (conditions := self.hass.data[DOMAIN].get(DATA_CONDITION)) - ): - return conditions.get(ccode) + if data.condition and (ccode := data.condition.get(CONDCODE)): + return CONDITION_MAP.get(ccode) + return None - @property - def native_temperature(self): - """Return the current temperature.""" - return self._data.temperature - - @property - def native_pressure(self): - """Return the current pressure.""" - return self._data.pressure - - @property - def humidity(self): - """Return the name of the sensor.""" - return self._data.humidity - - @property - def native_visibility(self): - """Return the current visibility in m.""" - return self._data.visibility - - @property - def native_wind_speed(self): - """Return the current windspeed in m/s.""" - return self._data.wind_speed - - @property - def wind_bearing(self): - """Return the current wind bearing (degrees).""" - return self._data.wind_bearing - - @property - def forecast(self): + def _calc_forecast(self, data: BrData): """Return the forecast array.""" fcdata_out = [] - cond = self.hass.data[DOMAIN][DATA_CONDITION] - if not self._data.forecast: + if not data.forecast: return None - for data_in in self._data.forecast: + for data_in in data.forecast: # remap keys from external library to # keys understood by the weather component: - condcode = data_in.get(CONDITION, []).get(CONDCODE) + condcode = data_in.get(CONDITION, {}).get(CONDCODE) data_out = { ATTR_FORECAST_TIME: data_in.get(DATETIME).isoformat(), - ATTR_FORECAST_CONDITION: cond[condcode], + ATTR_FORECAST_CONDITION: CONDITION_MAP.get(condcode), ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), @@ -212,3 +192,7 @@ class BrWeather(WeatherEntity): fcdata_out.append(data_out) return fcdata_out + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self._attr_forecast diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index e4892ae0383..f30f79f7275 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -43,7 +43,6 @@ OFFSET = "!!" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - # pylint: disable=no-value-for-parameter vol.Required(CONF_URL): vol.Url(), vol.Optional(CONF_CALENDARS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, @@ -87,6 +86,7 @@ def setup_platform( calendars = client.principal().calendars() calendar_devices = [] + device_id: str | None for calendar in list(calendars): # If a calendar name was given in the configuration, # ignore all the others @@ -105,7 +105,12 @@ def setup_platform( entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) calendar_devices.append( WebDavCalendarEntity( - name, calendar, entity_id, days, True, cust_calendar[CONF_SEARCH] + name=name, + calendar=calendar, + entity_id=entity_id, + days=days, + all_day=True, + search=cust_calendar[CONF_SEARCH], ) ) @@ -126,7 +131,12 @@ class WebDavCalendarEntity(CalendarEntity): def __init__(self, name, calendar, entity_id, days, all_day=False, search=None): """Create the WebDav Calendar Event Device.""" - self.data = WebDavCalendarData(calendar, days, all_day, search) + self.data = WebDavCalendarData( + calendar=calendar, + days=days, + include_all_day=all_day, + search=search, + ) self.entity_id = entity_id self._event: CalendarEvent | None = None self._attr_name = name @@ -347,10 +357,8 @@ class WebDavCalendarData: """Return the end datetime as determined by dtend or duration.""" if hasattr(obj, "dtend"): enddate = obj.dtend.value - elif hasattr(obj, "duration"): enddate = obj.dtstart.value + obj.duration.value - else: enddate = obj.dtstart.value + timedelta(days=1) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 16624f2af56..92e2f7e67d8 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.2.0"] + "requirements": ["caldav==1.3.6"] } diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index c85f0d2bff1..e487569453f 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -20,10 +20,12 @@ from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import ( + CALLBACK_TYPE, HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -34,6 +36,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -478,6 +481,8 @@ def is_offset_reached( class CalendarEntity(Entity): """Base class for calendar event entities.""" + _alarm_unsubs: list[CALLBACK_TYPE] = [] + @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" @@ -513,6 +518,48 @@ class CalendarEntity(Entity): return STATE_OFF + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine. + + This sets up listeners to handle state transitions for start or end of + the current or upcoming event. + """ + super().async_write_ha_state() + + for unsub in self._alarm_unsubs: + unsub() + + now = dt_util.now() + event = self.event + if event is None or now >= event.end_datetime_local: + return + + @callback + def update(_: datetime.datetime) -> None: + """Run when the active or upcoming event starts or ends.""" + self._async_write_ha_state() + + if now < event.start_datetime_local: + self._alarm_unsubs.append( + async_track_point_in_time( + self.hass, + update, + event.start_datetime_local, + ) + ) + self._alarm_unsubs.append( + async_track_point_in_time(self.hass, update, event.end_datetime_local) + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass. + + To be extended by integrations. + """ + for unsub in self._alarm_unsubs: + unsub() + async def async_get_events( self, hass: HomeAssistant, diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 277aa10075e..07394ca75b2 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -15,7 +15,6 @@ from random import SystemRandom from typing import Any, Final, cast, final from aiohttp import hdrs, web -import async_timeout import attr import voluptuous as vol @@ -168,10 +167,15 @@ async def _async_get_image( are handled. """ with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(timeout): - if image_bytes := await camera.async_camera_image( - width=width, height=height - ): + async with asyncio.timeout(timeout): + image_bytes = ( + await _async_get_stream_image( + camera, width=width, height=height, wait_for_next_keyframe=False + ) + if camera.use_stream_for_stills + else await camera.async_camera_image(width=width, height=height) + ) + if image_bytes: content_type = camera.content_type image = Image(content_type, image_bytes) if ( @@ -206,6 +210,21 @@ async def async_get_image( return await _async_get_image(camera, timeout, width, height) +async def _async_get_stream_image( + camera: Camera, + width: int | None = None, + height: int | None = None, + wait_for_next_keyframe: bool = False, +) -> bytes | None: + if not camera.stream and camera.supported_features & SUPPORT_STREAM: + camera.stream = await camera.async_create_stream() + if camera.stream: + return await camera.stream.async_get_image( + width=width, height=height, wait_for_next_keyframe=wait_for_next_keyframe + ) + return None + + @bind_hass async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" @@ -247,8 +266,8 @@ async def async_get_still_stream( await response.write( bytes( "--frameboundary\r\n" - "Content-Type: {}\r\n" - "Content-Length: {}\r\n\r\n".format(content_type, len(img_bytes)), + f"Content-Type: {content_type}\r\n" + f"Content-Length: {len(img_bytes)}\r\n\r\n", "utf-8", ) + img_bytes @@ -361,6 +380,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) async def preload_stream(_event: Event) -> None: + """Load stream prefs and start stream if preload_stream is True.""" for camera in list(component.entities): stream_prefs = await prefs.get_dynamic_stream_settings(camera.entity_id) if not stream_prefs.preload_stream: @@ -460,6 +480,11 @@ class Camera(Entity): return self._attr_entity_picture return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return False + @property def supported_features(self) -> CameraEntityFeature: """Flag supported features.""" @@ -525,7 +550,7 @@ class Camera(Entity): self._create_stream_lock = asyncio.Lock() async with self._create_stream_lock: if not self.stream: - async with async_timeout.timeout(CAMERA_STREAM_SOURCE_TIMEOUT): + async with asyncio.timeout(CAMERA_STREAM_SOURCE_TIMEOUT): source = await self.stream_source() if not source: return None @@ -927,7 +952,12 @@ async def async_handle_snapshot_service( f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" ) - image = await camera.async_camera_image() + async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT): + image = ( + await _async_get_stream_image(camera, wait_for_next_keyframe=True) + if camera.use_stream_for_stills + else await camera.async_camera_image() + ) if image is None: return diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 04d8d159541..af78dceca23 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index d81589020e3..1b47d6d70b7 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -1,11 +1,11 @@ """Provides the Canary DataUpdateCoordinator.""" from __future__ import annotations +import asyncio from collections.abc import ValuesView from datetime import timedelta import logging -from async_timeout import timeout from canary.api import Api from canary.model import Location, Reading from requests.exceptions import ConnectTimeout, HTTPError @@ -58,7 +58,7 @@ class CanaryDataUpdateCoordinator(DataUpdateCoordinator[CanaryData]): """Fetch data from Canary.""" try: - async with timeout(15): + async with asyncio.timeout(15): return await self.hass.async_add_executor_job(self._update_data) except (ConnectTimeout, HTTPError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 90cb20a6c6c..bdba9d4f130 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index d32ff07c261..b472b18bed0 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -46,8 +46,8 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 4fc89bc918b..5d1e68a951f 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -5,11 +5,7 @@ from datetime import datetime, timedelta import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - Platform, -) +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -65,10 +61,7 @@ class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): name = f"{self.host}{display_port}" super().__init__( - hass, - _LOGGER, - name=name, - update_interval=SCAN_INTERVAL, + hass, _LOGGER, name=name, update_interval=SCAN_INTERVAL, always_update=False ) async def _async_update_data(self) -> datetime | None: diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 56bcf07a3bb..645642067e6 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -14,6 +14,7 @@ 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 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.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -76,6 +77,7 @@ class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]): """Defines a base Cert Expiry entity.""" _attr_icon = "mdi:certificate" + _attr_has_entity_name = True @property def extra_state_attributes(self): @@ -90,6 +92,7 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): """Implementation of the Cert Expiry timestamp sensor.""" _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_translation_key = "certificate_expiry" def __init__( self, @@ -97,8 +100,12 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): ) -> None: """Initialize a Cert Expiry timestamp sensor.""" super().__init__(coordinator) - self._attr_name = f"Cert Expiry Timestamp ({coordinator.name})" self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.host}:{coordinator.port}")}, + name=coordinator.name, + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> datetime | None: diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json index 5c8af4df931..b8c7ffe037f 100644 --- a/homeassistant/components/cert_expiry/strings.json +++ b/homeassistant/components/cert_expiry/strings.json @@ -20,5 +20,12 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "import_failed": "Import from config failed" } + }, + "entity": { + "sensor": { + "certificate_expiry": { + "name": "Cert expiry" + } + } } } diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index c87427e0e7e..fcd780dba7d 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -6,7 +6,6 @@ from datetime import timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import ( @@ -140,7 +139,7 @@ async def async_citybikes_request(hass, uri, schema): try: session = async_get_clientsession(hass) - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): req = await session.get(DEFAULT_ENDPOINT.format(uri=uri)) json_response = await req.json() diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 8c1300f6228..e85c6dd277a 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -10,8 +10,8 @@ import logging from typing import TYPE_CHECKING, Any import aiohttp -import async_timeout from hass_nabucasa import Cloud, cloud_api +from yarl import URL from homeassistant.components import persistent_notification from homeassistant.components.alexa import ( @@ -149,7 +149,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._token_valid: datetime | None = None self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None - self._endpoint: Any = None + self._endpoint: str | URL | None = None @property def enabled(self) -> bool: @@ -175,7 +175,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) @property - def endpoint(self) -> Any | None: + def endpoint(self) -> str | URL | None: """Endpoint for report state.""" if self._endpoint is None: raise ValueError("No endpoint available. Fetch access token first") @@ -309,7 +309,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): """Invalidate access token.""" self._token_valid = None - async def async_get_access_token(self) -> Any: + async def async_get_access_token(self) -> str | None: """Get an access token.""" if self._token_valid is not None and self._token_valid > utcnow(): return self._token @@ -500,7 +500,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) return True diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 236635a0bb8..c216ec85c5c 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -54,7 +54,6 @@ class CloudClient(Interface): @property def base_path(self) -> Path: """Return path to base dir.""" - assert self._hass.config.config_dir is not None return Path(self._hass.config.config_dir) @property @@ -222,6 +221,7 @@ class CloudClient(Interface): "connected": self.cloud.remote.is_connected, "enabled": self._prefs.remote_enabled, "instance_domain": self.cloud.remote.instance_domain, + "alias": self.cloud.remote.alias, }, "version": HA_VERSION, } @@ -230,7 +230,7 @@ class CloudClient(Interface): """Process cloud alexa message to client.""" cloud_user = await self._prefs.get_cloud_user() aconfig = await self.get_alexa_config() - return await alexa_smart_home.async_handle_message( # type: ignore[no-any-return, no-untyped-call] + return await alexa_smart_home.async_handle_message( self._hass, aconfig, payload, diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 84c348236d4..e3b1b39f687 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -10,7 +10,6 @@ from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp from aiohttp import web -import async_timeout import attr from hass_nabucasa import Cloud, auth, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED @@ -24,7 +23,7 @@ from homeassistant.components.alexa import ( ) from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.homeassistant import exposed_entities -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant @@ -128,7 +127,6 @@ def _handle_cloud_errors( try: result = await handler(view, request, *args, **kwargs) return result - except Exception as err: # pylint: disable=broad-except status, msg = _process_cloud_exception(err, request.path) return view.json_message( @@ -188,6 +186,7 @@ class GoogleActionsSyncView(HomeAssistantView): url = "/api/cloud/google_actions/sync" name = "api:cloud:google_actions/sync" + @require_admin @_handle_cloud_errors async def post(self, request: web.Request) -> web.Response: """Trigger a Google Actions sync.""" @@ -204,6 +203,7 @@ class CloudLoginView(HomeAssistantView): url = "/api/cloud/login" name = "api:cloud:login" + @require_admin @_handle_cloud_errors @RequestDataValidator( vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) @@ -244,13 +244,14 @@ class CloudLogoutView(HomeAssistantView): url = "/api/cloud/logout" name = "api:cloud:logout" + @require_admin @_handle_cloud_errors async def post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.logout() return self.json_message("ok") @@ -262,6 +263,7 @@ class CloudRegisterView(HomeAssistantView): url = "/api/cloud/register" name = "api:cloud:register" + @require_admin @_handle_cloud_errors @RequestDataValidator( vol.Schema( @@ -289,7 +291,7 @@ class CloudRegisterView(HomeAssistantView): if location_info.zip_code is not None: client_metadata["NC_ZIP_CODE"] = location_info.zip_code - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_register( data["email"], data["password"], @@ -305,6 +307,7 @@ class CloudResendConfirmView(HomeAssistantView): url = "/api/cloud/resend_confirm" name = "api:cloud:resend_confirm" + @require_admin @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: @@ -312,7 +315,7 @@ class CloudResendConfirmView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_resend_email_confirm(data["email"]) return self.json_message("ok") @@ -324,6 +327,7 @@ class CloudForgotPasswordView(HomeAssistantView): url = "/api/cloud/forgot_password" name = "api:cloud:forgot_password" + @require_admin @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: @@ -331,7 +335,7 @@ class CloudForgotPasswordView(HomeAssistantView): hass = request.app["hass"] cloud = hass.data[DOMAIN] - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_forgot_password(data["email"]) return self.json_message("ok") @@ -434,7 +438,7 @@ async def websocket_update_prefs( if changes.get(PREF_ALEXA_REPORT_STATE): alexa_config = await cloud.client.get_alexa_config() try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await alexa_config.async_get_access_token() except asyncio.TimeoutError: connection.send_error( @@ -774,7 +778,7 @@ async def alexa_sync( cloud = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: success = await alexa_config.async_sync_entities() except alexa_errors.NoTokenAvailable: @@ -803,7 +807,7 @@ async def thingtalk_convert( """Convert a query.""" cloud = hass.data[DOMAIN] - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: connection.send_result( msg["id"], await thingtalk.async_convert(cloud, msg["query"]) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d8fd2148b4d..a8e28d66291 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.69.0"] + "requirements": ["hass-nabucasa==0.70.0"] } diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index 633f0c95e1b..9a62f2d115c 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -6,7 +6,6 @@ import logging from typing import Any from aiohttp.client_exceptions import ClientError -import async_timeout from hass_nabucasa import Cloud, cloud_api from .client import CloudClient @@ -18,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None: """Fetch the subscription info.""" try: - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_subscription_info(cloud) except asyncio.TimeoutError: _LOGGER.error( @@ -39,7 +38,7 @@ async def async_migrate_paypal_agreement( ) -> dict[str, Any] | None: """Migrate a paypal agreement from legacy.""" try: - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_migrate_paypal_agreement(cloud) except asyncio.TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index ae22fb7b7ef..c5bc7eb4c20 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index abd113e71ad..47fd3b91129 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -6,8 +6,7 @@ import logging 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 -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import CoinbaseData diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index d0a6b53964b..fb04ebb76a4 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -4,7 +4,6 @@ import io import logging import aiohttp -import async_timeout from colorthief import ColorThief from PIL import UnidentifiedImageError import voluptuous as vol @@ -82,7 +81,7 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: except UnidentifiedImageError as ex: _LOGGER.error( "Bad image from %s '%s' provided, are you sure it's an image? %s", - image_type, # pylint: disable=used-before-assignment + image_type, image_reference, ex, ) @@ -120,7 +119,7 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: try: session = aiohttp_client.async_get_clientsession(hass) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await session.get(url) except (asyncio.TimeoutError, aiohttp.ClientError) as err: diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 3336f5b79f8..ef974b8f3ed 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -7,7 +7,6 @@ import json import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import ( @@ -112,7 +111,7 @@ class ComedHourlyPricingSensor(SensorEntity): else: url_string += "?type=currenthouraverage" - async with async_timeout.timeout(60): + async with asyncio.timeout(60): response = await self.websession.get(url_string) # The API responds with MIME type 'text/html' text = await response.text() diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py new file mode 100644 index 00000000000..2c73922582c --- /dev/null +++ b/homeassistant/components/comelit/__init__.py @@ -0,0 +1,34 @@ +"""Comelit integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PIN, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + +PLATFORMS = [Platform.LIGHT] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Comelit platform.""" + coordinator = ComelitSerialBridge(hass, entry.data[CONF_HOST], entry.data[CONF_PIN]) + + 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): + coordinator: ComelitSerialBridge = hass.data[DOMAIN][entry.entry_id] + await coordinator.api.logout() + await coordinator.api.close() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py new file mode 100644 index 00000000000..dd6227a6583 --- /dev/null +++ b/homeassistant/components/comelit/config_flow.py @@ -0,0 +1,145 @@ +"""Config flow for Comelit integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiocomelit import ComeliteSerialBridgeAPi, exceptions as aiocomelit_exceptions +import voluptuous as vol + +from homeassistant import core, exceptions +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.data_entry_flow import FlowResult + +from .const import _LOGGER, DOMAIN + +DEFAULT_HOST = "192.168.1.252" +DEFAULT_PIN = "111111" + + +def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: + """Return user form schema.""" + user_input = user_input or {} + return vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): str, + } + ) + + +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str}) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + + api = ComeliteSerialBridgeAPi(data[CONF_HOST], data[CONF_PIN]) + + try: + await api.login() + except aiocomelit_exceptions.CannotConnect as err: + raise CannotConnect from err + except aiocomelit_exceptions.CannotAuthenticate as err: + raise InvalidAuth from err + finally: + await api.logout() + await api.close() + + return {"title": data[CONF_HOST]} + + +class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Comelit.""" + + VERSION = 1 + _reauth_entry: ConfigEntry | None + _reauth_host: str + + 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=user_form_schema(user_input) + ) + + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=user_form_schema(user_input), errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth flow.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._reauth_host = entry_data[CONF_HOST] + self.context["title_placeholders"] = {"host": self._reauth_host} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauth confirm.""" + assert self._reauth_entry + errors = {} + + if user_input is not None: + try: + await validate_input( + self.hass, {CONF_HOST: self._reauth_host} | user_input + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={ + CONF_HOST: self._reauth_host, + CONF_PIN: user_input[CONF_PIN], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_HOST: self._reauth_entry.data[CONF_HOST]}, + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py new file mode 100644 index 00000000000..e08caa55f76 --- /dev/null +++ b/homeassistant/components/comelit/const.py @@ -0,0 +1,6 @@ +"""Comelit constants.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "comelit" diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py new file mode 100644 index 00000000000..beb7266c403 --- /dev/null +++ b/homeassistant/components/comelit/coordinator.py @@ -0,0 +1,50 @@ +"""Support for Comelit.""" +import asyncio +from datetime import timedelta +from typing import Any + +from aiocomelit import ComeliteSerialBridgeAPi +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import _LOGGER, DOMAIN + + +class ComelitSerialBridge(DataUpdateCoordinator): + """Queries Comelit Serial Bridge.""" + + def __init__(self, hass: HomeAssistant, host: str, pin: int) -> None: + """Initialize the scanner.""" + + self._host = host + self._pin = pin + + self.api = ComeliteSerialBridgeAPi(host, pin) + + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{DOMAIN}-{host}-coordinator", + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update router data.""" + _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) + try: + logged = await self.api.login() + except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: + _LOGGER.warning("Connection error for %s", self._host) + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + + if not logged: + raise ConfigEntryAuthFailed + + devices_data = await self.api.get_all_devices() + alarm_data = await self.api.get_alarm_config() + await self.api.logout() + + return devices_data | alarm_data diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py new file mode 100644 index 00000000000..9a893bd929c --- /dev/null +++ b/homeassistant/components/comelit/light.py @@ -0,0 +1,78 @@ +"""Support for lights.""" +from __future__ import annotations + +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import LIGHT, LIGHT_OFF, LIGHT_ON + +from homeassistant.components.light import LightEntity +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 .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit lights.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + async_add_entities( + ComelitLightEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[LIGHT].values() + ) + + +class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): + """Light device.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_unique_id: str | None, + ) -> None: + """Init light entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry_unique_id}-{device.index}" + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self._attr_unique_id), + }, + manufacturer="Comelit", + model="Serial Bridge", + name=device.name, + ) + + async def _light_set_state(self, state: int) -> None: + """Set desired light state.""" + await self.coordinator.api.light_switch(self._device.index, state) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + await self._light_set_state(LIGHT_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._light_set_state(LIGHT_OFF) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.coordinator.data[LIGHT][self._device.index].status == LIGHT_ON diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json new file mode 100644 index 00000000000..fc7f2a3fc12 --- /dev/null +++ b/homeassistant/components/comelit/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "comelit", + "name": "Comelit SimpleHome", + "codeowners": ["@chemelli74"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/comelit", + "iot_class": "local_polling", + "loggers": ["aiocomelit"], + "requirements": ["aiocomelit==0.0.5"] +} diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json new file mode 100644 index 00000000000..6508f58412e --- /dev/null +++ b/homeassistant/components/comelit/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "{host}", + "step": { + "reauth_confirm": { + "description": "Please enter the correct PIN for VEDO system: {host}", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "pin": "[%key:common::config_flow::data::pin%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "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%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index f2097178a95..1d6ee9046e8 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -29,7 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 553af2f0c86..2aa67cec641 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 2ccbdbc4785..a617d348c8d 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -16,13 +16,12 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, SensorDeviceClass, - SensorEntity, - SensorStateClass, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, @@ -36,7 +35,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerSensorEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -47,6 +50,16 @@ CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + SCAN_INTERVAL = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -87,30 +100,25 @@ async def async_setup_platform( name: str = sensor_config[CONF_NAME] command: str = sensor_config[CONF_COMMAND] - unit: str | None = sensor_config.get(CONF_UNIT_OF_MEASUREMENT) value_template: Template | None = sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = sensor_config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = sensor_config.get(CONF_UNIQUE_ID) if value_template is not None: value_template.hass = hass json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - state_class: SensorStateClass | None = sensor_config.get(CONF_STATE_CLASS) data = CommandSensorData(hass, command, command_timeout) - trigger_entity_config = { - CONF_UNIQUE_ID: unique_id, - CONF_NAME: Template(name, hass), - CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), - } + trigger_entity_config = {CONF_NAME: Template(name, hass)} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in sensor_config: + continue + trigger_entity_config[key] = sensor_config[key] async_add_entities( [ CommandSensor( data, trigger_entity_config, - unit, - state_class, value_template, json_attributes, scan_interval, @@ -119,7 +127,7 @@ async def async_setup_platform( ) -class CommandSensor(ManualTriggerEntity, SensorEntity): +class CommandSensor(ManualTriggerSensorEntity): """Representation of a sensor that is using shell commands.""" _attr_should_poll = False @@ -128,8 +136,6 @@ class CommandSensor(ManualTriggerEntity, SensorEntity): self, data: CommandSensorData, config: ConfigType, - unit_of_measurement: str | None, - state_class: SensorStateClass | None, value_template: Template | None, json_attributes: list[str] | None, scan_interval: timedelta, @@ -141,8 +147,6 @@ class CommandSensor(ManualTriggerEntity, SensorEntity): self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_state_class = state_class self._scan_interval = scan_interval self._process_updates: asyncio.Lock | None = None diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 8fbafd7a4d1..004a65643bb 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -32,7 +32,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify diff --git a/homeassistant/components/coned/__init__.py b/homeassistant/components/coned/__init__.py new file mode 100644 index 00000000000..d5130f53d05 --- /dev/null +++ b/homeassistant/components/coned/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Consolidated Edison (ConEd).""" diff --git a/homeassistant/components/coned/manifest.json b/homeassistant/components/coned/manifest.json new file mode 100644 index 00000000000..9e1f0ef6a4f --- /dev/null +++ b/homeassistant/components/coned/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "coned", + "name": "Consolidated Edison (ConEd)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 514154137e4..84a1c2eaa17 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -7,7 +7,7 @@ import os import voluptuous as vol from homeassistant.components import frontend -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -103,6 +103,7 @@ class BaseEditConfigView(HomeAssistantView): """Delete value.""" raise NotImplementedError + @require_admin async def get(self, request, config_key): """Fetch device specific config.""" hass = request.app["hass"] @@ -115,6 +116,7 @@ class BaseEditConfigView(HomeAssistantView): return self.json(value) + @require_admin async def post(self, request, config_key): """Validate config and return results.""" try: @@ -156,6 +158,7 @@ class BaseEditConfigView(HomeAssistantView): return self.json({"result": "ok"}) + @require_admin async def delete(self, request, config_key): """Remove an entry.""" hass = request.app["hass"] diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d58616ff38f..77e2548d424 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized import homeassistant.helpers.config_validation as cv @@ -138,13 +138,11 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): """Not implemented.""" raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + ) async def post(self, request): """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - - # pylint: disable=no-value-for-parameter try: return await super().post(request) except DependencyError as exc: @@ -164,20 +162,18 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): url = "/api/config/config_entries/flow/{flow_id}" name = "api:config:config_entries:flow:resource" - async def get(self, request, flow_id): + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + ) + async def get(self, request, /, flow_id): """Get the current state of a data_entry_flow.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - return await super().get(request, flow_id) - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") + ) async def post(self, request, flow_id): """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission="add") - - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) def _prepare_result_json(self, result): @@ -206,16 +202,14 @@ class OptionManagerFlowIndexView(FlowManagerIndexView): url = "/api/config/config_entries/options/flow" name = "api:config:config_entries:option:flow" - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) async def post(self, request): """Handle a POST request. handler in request is entry_id. """ - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - - # pylint: disable=no-value-for-parameter return await super().post(request) @@ -225,20 +219,18 @@ class OptionManagerFlowResourceView(FlowManagerResourceView): url = "/api/config/config_entries/options/flow/{flow_id}" name = "api:config:config_entries:options:flow:resource" - async def get(self, request, flow_id): + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + async def get(self, request, /, flow_id): """Get the current state of a data_entry_flow.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - return await super().get(request, flow_id) - # pylint: disable=arguments-differ + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) async def post(self, request, flow_id): """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) - - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 999e9433cbb..9771e12f1d6 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -5,7 +5,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.sensor import async_update_suggested_units from homeassistant.config import async_check_ha_config_file from homeassistant.core import HomeAssistant @@ -28,6 +28,7 @@ class CheckConfigView(HomeAssistantView): url = "/api/config/core/check_config" name = "api:config:core:check_config" + @require_admin async def post(self, request): """Validate configuration and return results.""" errors = await async_check_ha_config_file(request.app["hass"]) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index de4c8208ee0..63cbd9351c7 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 04aafc8a99d..09245fde8dc 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -54,9 +54,7 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) -TRIGGER_CALLBACK_TYPE = Callable[ # pylint: disable=invalid-name - [str, RecognizeResult], Awaitable[str | None] -] +TRIGGER_CALLBACK_TYPE = Callable[[str, RecognizeResult], Awaitable[str | None]] def json_load(fp: IO[str]) -> JsonObjectType: diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index 6ae6613bcca..c9f5cff4339 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -58,12 +58,8 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): def __init__(self, coordinator, unit_id, info, supported_modes): """Initialize the climate device.""" super().__init__(coordinator, unit_id, info) - self._hvac_modes = supported_modes - - @property - def unique_id(self): - """Return unique ID for this device.""" - return self._unit_id + self._attr_hvac_modes = supported_modes + self._attr_unique_id = unit_id @property def supported_features(self) -> ClimateEntityFeature: @@ -102,11 +98,6 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): return CM_TO_HA_STATE[mode] - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return self._hvac_modes - @property def fan_mode(self): """Return the fan setting.""" diff --git a/homeassistant/components/coolmaster/entity.py b/homeassistant/components/coolmaster/entity.py index 1607e220a55..66572a56254 100644 --- a/homeassistant/components/coolmaster/entity.py +++ b/homeassistant/components/coolmaster/entity.py @@ -2,7 +2,7 @@ from pycoolmasternet_async.coolmasternet import CoolMasterNetUnit from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import CoolmasterDataUpdateCoordinator diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index 7eb3cfab753..5eb05afd014 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfFrequency from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/crownstone/devices.py b/homeassistant/components/crownstone/devices.py index 83aaac95393..5645d3edd1f 100644 --- a/homeassistant/components/crownstone/devices.py +++ b/homeassistant/components/crownstone/devices.py @@ -3,7 +3,8 @@ from __future__ import annotations from crownstone_cloud.cloud_models.crownstones import Crownstone -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import CROWNSTONE_INCLUDE_TYPES, DOMAIN diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 3ef9c0aba62..f6fd399f855 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -4,7 +4,6 @@ from datetime import timedelta import logging from aiohttp import ClientConnectionError -from async_timeout import timeout from pydaikin.daikin_base import Appliance from homeassistant.config_entries import ConfigEntry @@ -20,8 +19,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.util import Throttle from .const import DOMAIN, KEY_MAC, TIMEOUT @@ -75,7 +73,7 @@ async def daikin_api_setup(hass: HomeAssistant, host, key, uuid, password): session = async_get_clientsession(hass) try: - async with timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): device = await Appliance.factory( host, session, key=key, uuid=uuid, password=password ) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 5ede11c60b6..c848e0b703e 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -124,14 +124,16 @@ class DaikinClimate(ClimateEntity): _attr_name = None _attr_has_entity_name = True _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = list(HA_STATE_TO_DAIKIN) + _attr_target_temperature_step = 1 def __init__(self, api: DaikinApi) -> None: """Initialize the climate device.""" self._api = api - self._attr_hvac_modes = list(HA_STATE_TO_DAIKIN) - self._attr_fan_modes = self._api.device.fan_rate - self._attr_swing_modes = self._api.device.swing_modes + 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 = { ATTR_HVAC_MODE: self._attr_hvac_modes, ATTR_FAN_MODE: self._attr_fan_modes, @@ -140,16 +142,13 @@ class DaikinClimate(ClimateEntity): self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if ( - self._api.device.support_away_mode - or self._api.device.support_advanced_modes - ): + if api.device.support_away_mode or api.device.support_advanced_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE - if self._api.device.support_fan_rate: + if api.device.support_fan_rate: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - if self._api.device.support_swing_mode: + if api.device.support_swing_mode: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE async def _set(self, settings): @@ -195,11 +194,6 @@ class DaikinClimate(ClimateEntity): """Return the temperature we try to reach.""" return self._api.device.target_temperature - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 - async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self._set(kwargs) @@ -310,8 +304,3 @@ class DaikinClimate(ClimateEntity): await self._api.device.set( {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]} ) - - @property - def device_info(self): - """Return a device description for device registry.""" - return self._api.device_info diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index a64f2059972..2d5d1e12dfd 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -4,7 +4,6 @@ import logging from uuid import uuid4 from aiohttp import ClientError, web_exceptions -from async_timeout import timeout from pydaikin.daikin_base import Appliance, DaikinException from pydaikin.discovery import Discovery import voluptuous as vol @@ -70,7 +69,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): password = None try: - async with timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): device = await Appliance.factory( host, async_get_clientsession(self.hass), diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index c6334dfaeca..7c7f5ce7f2a 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["pydaikin"], "quality_scale": "platinum", - "requirements": ["pydaikin==2.10.5"], + "requirements": ["pydaikin==2.11.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index ae5f1008820..1646e292ee9 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -21,7 +21,6 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -192,13 +191,10 @@ class DaikinSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self.entity_description = description + self._attr_device_info = api.device_info + self._attr_unique_id = f"{api.device.mac}-{description.key}" self._api = api - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._api.device.mac}-{self.entity_description.key}" - @property def native_value(self) -> float | None: """Return the state of the sensor.""" @@ -207,8 +203,3 @@ class DaikinSensor(SensorEntity): async def async_update(self) -> None: """Retrieve latest state.""" await self._api.async_update() - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return self._api.device_info diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 847f030fae5..8dd75916685 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -6,7 +6,6 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -59,15 +58,12 @@ class DaikinZoneSwitch(SwitchEntity): _attr_icon = ZONE_ICON _attr_has_entity_name = True - def __init__(self, daikin_api: DaikinApi, zone_id) -> None: + def __init__(self, api: DaikinApi, zone_id) -> None: """Initialize the zone.""" - self._api = daikin_api + self._api = api self._zone_id = zone_id - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._api.device.mac}-zone{self._zone_id}" + self._attr_device_info = api.device_info + self._attr_unique_id = f"{api.device.mac}-zone{zone_id}" @property def name(self) -> str: @@ -79,11 +75,6 @@ class DaikinZoneSwitch(SwitchEntity): """Return the state of the sensor.""" return self._api.device.zones[self._zone_id][1] == "1" - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return self._api.device_info - async def async_update(self) -> None: """Retrieve latest state.""" await self._api.async_update() @@ -104,14 +95,11 @@ class DaikinStreamerSwitch(SwitchEntity): _attr_name = "Streamer" _attr_has_entity_name = True - def __init__(self, daikin_api: DaikinApi) -> None: + def __init__(self, api: DaikinApi) -> None: """Initialize streamer switch.""" - self._api = daikin_api - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._api.device.mac}-streamer" + self._api = api + self._attr_device_info = api.device_info + self._attr_unique_id = f"{api.device.mac}-streamer" @property def is_on(self) -> bool: @@ -120,11 +108,6 @@ class DaikinStreamerSwitch(SwitchEntity): DAIKIN_ATTR_STREAMER in self._api.device.represent(DAIKIN_ATTR_ADVANCED)[1] ) - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return self._api.device_info - async def async_update(self) -> None: """Retrieve latest state.""" await self._api.async_update() diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index fb67f4b1ffb..b04008672ae 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging from typing import final @@ -110,7 +110,7 @@ class DateTimeEntity(Entity): "which is missing timezone information" ) - return value.astimezone(timezone.utc).isoformat(timespec="seconds") + return value.astimezone(UTC).isoformat(timespec="seconds") @property def native_value(self) -> datetime | None: diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 8eda93c2d46..c0361aa2bca 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -9,7 +9,6 @@ from pprint import pformat from typing import Any, cast from urllib.parse import urlparse -import async_timeout from pydeconz.errors import LinkButtonNotPressed, RequestError, ResponseError from pydeconz.gateway import DeconzSession from pydeconz.utils import ( @@ -101,7 +100,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): session = aiohttp_client.async_get_clientsession(self.hass) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self.bridges = await deconz_discovery(session) except (asyncio.TimeoutError, ResponseError): @@ -159,7 +158,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): deconz_session = DeconzSession(session, self.host, self.port) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): api_key = await deconz_session.get_api_key() except LinkButtonNotPressed: @@ -180,7 +179,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): session = aiohttp_client.async_get_clientsession(self.hass) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self.bridge_id = await deconz_get_bridge_id( session, self.host, self.port, self.api_key ) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 7b0c9383cb3..4c0f35266f9 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -11,9 +11,9 @@ from pydeconz.models.scene import Scene as PydeconzScene from pydeconz.models.sensor import SensorBase as PydeconzSensorBase from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import DOMAIN as DECONZ_DOMAIN from .gateway import DeconzGateway diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index a22c8cb9491..1b257d121b4 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -550,6 +550,7 @@ BUSCH_JAEGER_REMOTE = { SONOFF_SNZB_01_1_MODEL = "WB01" SONOFF_SNZB_01_2_MODEL = "WB-01" +SONOFF_SNZB_01P_MODEL = "SNZB-01P" SONOFF_SNZB_01_SWITCH = { (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1003}, @@ -639,6 +640,7 @@ REMOTES = { UBISYS_CONTROL_UNIT_C4_MODEL: UBISYS_CONTROL_UNIT_C4, SONOFF_SNZB_01_1_MODEL: SONOFF_SNZB_01_SWITCH, SONOFF_SNZB_01_2_MODEL: SONOFF_SNZB_01_SWITCH, + SONOFF_SNZB_01P_MODEL: SONOFF_SNZB_01_SWITCH, } TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index f4af7337427..156309c0903 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -7,7 +7,6 @@ from collections.abc import Callable from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast -import async_timeout from pydeconz import DeconzSession, errors from pydeconz.interfaces import sensors from pydeconz.interfaces.api_handlers import APIHandler, GroupedAPIHandler @@ -353,7 +352,7 @@ async def get_deconz_session( config[CONF_API_KEY], ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await deconz_session.refresh_state() return deconz_session diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 9f8011e3431..46d10a77271 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -29,7 +29,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hs_to_xy diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index b46732178b8..d060b69c3f6 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -8,8 +8,8 @@ import logging import time from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar -from bluepy.btle import BTLEException # pylint: disable=import-error -import decora # pylint: disable=import-error +from bluepy.btle import BTLEException +import decora import voluptuous as vol from homeassistant import util diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index c103636563c..a9d43736743 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging from typing import Any -# pylint: disable=import-error from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residence import Residence diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index 97605d08fe9..63412242dd0 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -17,8 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_WEB_PORT, DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 30b48ab60d9..5de61350039 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -87,7 +87,7 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] api = DelugeRPCClient( - host=host, port=port, username=username, password=password + host=host, port=port, username=username, password=password, decode_utf8=True ) try: await self.hass.async_add_executor_job(api.connect) diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 04eba5f0586..b40e1ede232 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -26,6 +26,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.CALENDAR, Platform.CLIMATE, Platform.COVER, Platform.DATE, @@ -54,7 +55,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.MAILBOX, Platform.NOTIFY, Platform.IMAGE_PROCESSING, - Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.WEATHER, ] diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index 236d4bbb1b0..21f4054b241 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index 3c0498fefef..4fefd75bb8c 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -5,7 +5,7 @@ from homeassistant.components import persistent_notification from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index 73b45a55640..b4200f1be89 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -1,23 +1,22 @@ -"""Demo platform that has two fake binary sensors.""" +"""Demo platform that has two fake calendars.""" from __future__ import annotations import datetime from homeassistant.components.calendar import CalendarEntity, CalendarEvent +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 import homeassistant.util.dt as dt_util -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 Demo Calendar platform.""" - add_entities( + """Set up the Demo Calendar config entry.""" + async_add_entities( [ DemoCalendar(calendar_data_future(), "Calendar 1"), DemoCalendar(calendar_data_current(), "Calendar 2"), diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index bfc2cd1a2e7..6639c125653 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -14,7 +14,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 42e30aa8336..93998eb1e8b 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -13,7 +13,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_utc_time_change diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py index 4129d0d392a..34d1909bebe 100644 --- a/homeassistant/components/demo/date.py +++ b/homeassistant/components/demo/date.py @@ -6,7 +6,7 @@ from datetime import date from homeassistant.components.date import DateEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py index b769f9baba3..63c8a5a7873 100644 --- a/homeassistant/components/demo/datetime.py +++ b/homeassistant/components/demo/datetime.py @@ -1,12 +1,12 @@ """Demo platform that offers a fake date/time entity.""" from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.components.datetime import DateTimeEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -23,7 +23,7 @@ async def async_setup_entry( DemoDateTime( "datetime", "Date and Time", - datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + datetime(2020, 1, 1, 12, 0, 0, tzinfo=UTC), "mdi:calendar-clock", False, ), diff --git a/homeassistant/components/demo/event.py b/homeassistant/components/demo/event.py index e9d26d9f54d..8bc720e2db7 100644 --- a/homeassistant/components/demo/event.py +++ b/homeassistant/components/demo/event.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index fbc35965dc4..d8451bdd683 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -106,7 +106,7 @@ class DemoLight(LightEntity): state: bool, available: bool = False, brightness: int = 180, - ct: int | None = None, # pylint: disable=invalid-name + ct: int | None = None, effect_list: list[str] | None = None, effect: str | None = None, hs_color: tuple[int, int] | None = None, diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 719b1078b8c..5bc0462769d 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -5,7 +5,7 @@ from homeassistant.components.number import NumberDeviceClass, NumberEntity, Num from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index 6349b10040c..2a50b0151b6 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index a1f7504762a..41057bc458f 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 49e06839be5..eac267c7c15 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index 7c243b73ea5..fecc1b95cf4 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.text import TextEntity, TextMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index 0384c0822f4..56ab715a7f7 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -6,7 +6,7 @@ from datetime import time from homeassistant.components.time import TimeEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index 6373c485037..747b3c130d9 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -11,7 +11,7 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 887a9212335..758b5075041 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -46,6 +46,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} WEATHER_UPDATE_INTERVAL = timedelta(minutes=30) @@ -237,9 +242,7 @@ class DemoWeather(WeatherEntity): @property def condition(self) -> str: """Return the weather condition.""" - return [ - k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v - ][0] + return CONDITION_MAP[self._condition.lower()] async def async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast.""" diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 67368596439..c3dfbeb1011 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import CONF_RECEIVER @@ -196,11 +196,10 @@ def async_log_errors( ) except DenonAvrError as err: available = False - _LOGGER.error( + _LOGGER.exception( "Error %s occurred in method %s for Denon AVR receiver", err, func.__name__, - exc_info=True, ) finally: if available and not self.available: @@ -217,6 +216,9 @@ def async_log_errors( class DenonDevice(MediaPlayerEntity): """Representation of a Denon Media Player Device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, receiver: DenonAVR, @@ -225,7 +227,6 @@ class DenonDevice(MediaPlayerEntity): update_audyssey: bool, ) -> None: """Initialize the device.""" - self._attr_name = receiver.name self._attr_unique_id = unique_id assert config_entry.unique_id self._attr_device_info = DeviceInfo( @@ -234,7 +235,7 @@ class DenonDevice(MediaPlayerEntity): identifiers={(DOMAIN, config_entry.unique_id)}, manufacturer=config_entry.data[CONF_MANUFACTURER], model=config_entry.data[CONF_MODEL], - name=config_entry.title, + name=receiver.name, ) self._attr_sound_mode_list = receiver.sound_mode_list diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index de9f06a0e88..ba77d2a3d4b 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( EventStateChangedData, diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 7d8d0791b4d..50f9acf3e1a 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -17,8 +17,9 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/devolo_home_control/devolo_device.py b/homeassistant/components/devolo_home_control/devolo_device.py index 5848f682626..e63e711ea6f 100644 --- a/homeassistant/components/devolo_home_control/devolo_device.py +++ b/homeassistant/components/devolo_home_control/devolo_device.py @@ -8,7 +8,8 @@ from devolo_home_control_api.devices.zwave import Zwave from devolo_home_control_api.homecontrol import HomeControl from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN from .subscriber import Subscriber diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 00d96ea53b3..f54fddc9a86 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -1,14 +1,15 @@ """The devolo Home Network integration.""" from __future__ import annotations +import asyncio import logging from typing import Any -import async_timeout from devolo_plc_api import Device from devolo_plc_api.device_api import ( ConnectedStationInfo, NeighborAPInfo, + UpdateFirmwareCheck, WifiGuestAccessGet, ) from devolo_plc_api.exceptions.device import ( @@ -37,6 +38,7 @@ from .const import ( DOMAIN, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, + REGULAR_FIRMWARE, SHORT_UPDATE_INTERVAL, SWITCH_GUEST_WIFI, SWITCH_LEDS, @@ -45,7 +47,9 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up devolo Home Network from a config entry.""" hass.data.setdefault(DOMAIN, {}) zeroconf_instance = await zeroconf.async_get_async_instance(hass) @@ -64,11 +68,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unable to connect to {entry.data[CONF_IP_ADDRESS]}" ) from err + hass.data[DOMAIN][entry.entry_id] = {"device": device} + + async def async_update_firmware_available() -> UpdateFirmwareCheck: + """Fetch data from API endpoint.""" + assert device.device + try: + async with asyncio.timeout(10): + return await device.device.async_check_firmware_available() + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + async def async_update_connected_plc_devices() -> LogicalNetwork: """Fetch data from API endpoint.""" assert device.plcnet try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -77,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -88,7 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.device.async_get_led_setting() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -97,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -106,7 +121,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -132,6 +147,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=async_update_led_status, update_interval=SHORT_UPDATE_INTERVAL, ) + if device.device and "update" in device.device.features: + coordinators[REGULAR_FIRMWARE] = DataUpdateCoordinator( + hass, + _LOGGER, + name=REGULAR_FIRMWARE, + update_method=async_update_firmware_available, + update_interval=LONG_UPDATE_INTERVAL, + ) if device.device and "wifi1" in device.device.features: coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( hass, @@ -155,11 +178,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=SHORT_UPDATE_INTERVAL, ) - hass.data[DOMAIN][entry.entry_id] = {"device": device, "coordinators": coordinators} - for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id]["coordinators"] = coordinators + await hass.config_entries.async_forward_entry_setups(entry, platforms(device)) entry.async_on_unload( @@ -190,4 +213,7 @@ def platforms(device: Device) -> set[Platform]: supported_platforms.add(Platform.BINARY_SENSOR) if device.device and "wifi1" in device.device.features: supported_platforms.add(Platform.DEVICE_TRACKER) + supported_platforms.add(Platform.IMAGE) + if device.device and "update" in device.device.features: + supported_platforms.add(Platform.UPDATE) return supported_platforms diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index 39016ac7916..ba3f5e5b815 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -21,8 +21,10 @@ CONNECTED_PLC_DEVICES = "connected_plc_devices" CONNECTED_TO_ROUTER = "connected_to_router" CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" IDENTIFY = "identify" +IMAGE_GUEST_WIFI = "image_guest_wifi" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" +REGULAR_FIRMWARE = "regular_firmware" RESTART = "restart" START_WPS = "start_wps" SWITCH_GUEST_WIFI = "switch_guest_wifi" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index e477df63bd2..56a1043d126 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -12,7 +12,8 @@ from devolo_plc_api.device_api import ( from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py new file mode 100644 index 00000000000..3670c42bc6b --- /dev/null +++ b/homeassistant/components/devolo_home_network/image.py @@ -0,0 +1,100 @@ +"""Platform for image integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +from typing import Any + +from devolo_plc_api import Device, wifi_qr_code +from devolo_plc_api.device_api import WifiGuestAccessGet + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import homeassistant.util.dt as dt_util + +from .const import DOMAIN, IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI +from .entity import DevoloCoordinatorEntity + + +@dataclass +class DevoloImageRequiredKeysMixin: + """Mixin for required keys.""" + + image_func: Callable[[WifiGuestAccessGet], bytes] + + +@dataclass +class DevoloImageEntityDescription( + ImageEntityDescription, DevoloImageRequiredKeysMixin +): + """Describes devolo image entity.""" + + +IMAGE_TYPES: dict[str, DevoloImageEntityDescription] = { + IMAGE_GUEST_WIFI: DevoloImageEntityDescription( + key=IMAGE_GUEST_WIFI, + entity_category=EntityCategory.DIAGNOSTIC, + image_func=partial(wifi_qr_code, omitsize=True), + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and sensors and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ + entry.entry_id + ]["coordinators"] + + entities: list[ImageEntity] = [] + entities.append( + DevoloImageEntity( + entry, + coordinators[SWITCH_GUEST_WIFI], + IMAGE_TYPES[IMAGE_GUEST_WIFI], + device, + ) + ) + async_add_entities(entities) + + +class DevoloImageEntity(DevoloCoordinatorEntity[WifiGuestAccessGet], ImageEntity): + """Representation of a devolo image.""" + + _attr_content_type = "image/svg+xml" + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[WifiGuestAccessGet], + description: DevoloImageEntityDescription, + device: Device, + ) -> None: + """Initialize entity.""" + self.entity_description: DevoloImageEntityDescription = description + super().__init__(entry, coordinator, device) + ImageEntity.__init__(self, coordinator.hass) + self._attr_image_last_updated = dt_util.utcnow() + self._data = self.coordinator.data + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if ( + self._data.ssid != self.coordinator.data.ssid + or self._data.key != self.coordinator.data.key + ): + self._data = self.coordinator.data + self._attr_image_last_updated = dt_util.utcnow() + super()._handle_coordinator_update() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return self.entity_description.image_func(self.coordinator.data) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index 54b65c17e60..a047437e980 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["devolo_plc_api"], "quality_scale": "platinum", - "requirements": ["devolo-plc-api==1.3.2"], + "requirements": ["devolo-plc-api==1.4.0"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index e2954c1c7ec..55a7920ab3e 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -48,6 +48,11 @@ "name": "Start WPS" } }, + "image": { + "image_guest_wifi": { + "name": "Guest Wifi credentials as QR code" + } + }, "sensor": { "connected_plc_devices": { "name": "Connected PLC devices" diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py new file mode 100644 index 00000000000..21f6edd862c --- /dev/null +++ b/homeassistant/components/devolo_home_network/update.py @@ -0,0 +1,132 @@ +"""Platform for update integration.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from devolo_plc_api.device import Device +from devolo_plc_api.device_api import UpdateFirmwareCheck +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable + +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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, REGULAR_FIRMWARE +from .entity import DevoloCoordinatorEntity + + +@dataclass +class DevoloUpdateRequiredKeysMixin: + """Mixin for required keys.""" + + latest_version: Callable[[UpdateFirmwareCheck], str] + update_func: Callable[[Device], Awaitable[bool]] + + +@dataclass +class DevoloUpdateEntityDescription( + UpdateEntityDescription, DevoloUpdateRequiredKeysMixin +): + """Describes devolo update entity.""" + + +UPDATE_TYPES: dict[str, DevoloUpdateEntityDescription] = { + REGULAR_FIRMWARE: DevoloUpdateEntityDescription( + key=REGULAR_FIRMWARE, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + latest_version=lambda data: data.new_firmware_version.split("_")[0], + update_func=lambda device: device.device.async_start_firmware_update(), # type: ignore[union-attr] + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and sensors and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ + entry.entry_id + ]["coordinators"] + + async_add_entities( + [ + DevoloUpdateEntity( + entry, + coordinators[REGULAR_FIRMWARE], + UPDATE_TYPES[REGULAR_FIRMWARE], + device, + ) + ] + ) + + +class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity): + """Representation of a devolo update.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + entity_description: DevoloUpdateEntityDescription + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + description: DevoloUpdateEntityDescription, + device: Device, + ) -> None: + """Initialize entity.""" + self.entity_description = description + super().__init__(entry, coordinator, device) + self._attr_translation_key = None + self._in_progress_old_version: str | None = None + + @property + def installed_version(self) -> str: + """Version currently in use.""" + return self.device.firmware_version + + @property + def latest_version(self) -> str: + """Latest version available for install.""" + if latest_version := self.entity_description.latest_version( + self.coordinator.data + ): + return latest_version + return self.device.firmware_version + + @property + def in_progress(self) -> bool: + """Update installation in progress.""" + return self._in_progress_old_version == self.installed_version + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Turn the entity on.""" + self._in_progress_old_version = self.installed_version + try: + await self.entity_description.update_func(self.device) + except DevicePasswordProtected as ex: + self.entry.async_start_reauth(self.hass) + raise HomeAssistantError( + f"Device {self.entry.title} require re-authenticatication to set or change the password" + ) from ex + except DeviceUnavailable as ex: + raise HomeAssistantError( + f"Device {self.entry.title} did not respond" + ) from ex diff --git a/homeassistant/components/dexcom/__init__.py b/homeassistant/components/dexcom/__init__.py index 137a884d201..2d7c2120758 100644 --- a/homeassistant/components/dexcom/__init__.py +++ b/homeassistant/components/dexcom/__init__.py @@ -50,22 +50,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SessionError as error: raise UpdateFailed(error) from error + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=async_update_data, + update_interval=SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR: DataUpdateCoordinator( - hass, - _LOGGER, - name=DOMAIN, - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - ), + COORDINATOR: coordinator, UNDO_UPDATE_LISTENER: entry.add_update_listener(update_listener), } - await hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ].async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index b9958dc7309..126d946e57d 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -5,8 +5,12 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME 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 homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import COORDINATOR, DOMAIN, GLUCOSE_TREND_ICON, GLUCOSE_VALUE_ICON, MG_DL @@ -22,25 +26,49 @@ async def async_setup_entry( unit_of_measurement = config_entry.options[CONF_UNIT_OF_MEASUREMENT] async_add_entities( [ - DexcomGlucoseTrendSensor(coordinator, username), - DexcomGlucoseValueSensor(coordinator, username, unit_of_measurement), + DexcomGlucoseTrendSensor(coordinator, username, config_entry.entry_id), + DexcomGlucoseValueSensor( + coordinator, username, config_entry.entry_id, unit_of_measurement + ), ], False, ) -class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): +class DexcomSensorEntity(CoordinatorEntity, SensorEntity): + """Base Dexcom sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, username: str, entry_id: str, key: str + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{username}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name=username, + ) + + +class DexcomGlucoseValueSensor(DexcomSensorEntity): """Representation of a Dexcom glucose value sensor.""" _attr_icon = GLUCOSE_VALUE_ICON + _attr_translation_key = "glucose_value" - def __init__(self, coordinator, username, unit_of_measurement): + def __init__( + self, + coordinator: DataUpdateCoordinator, + username: str, + entry_id: str, + unit_of_measurement: str, + ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, username, entry_id, "value") self._attr_native_unit_of_measurement = unit_of_measurement self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l" - self._attr_name = f"{DOMAIN}_{username}_glucose_value" - self._attr_unique_id = f"{username}-value" @property def native_value(self): @@ -50,14 +78,16 @@ class DexcomGlucoseValueSensor(CoordinatorEntity, SensorEntity): return None -class DexcomGlucoseTrendSensor(CoordinatorEntity, SensorEntity): +class DexcomGlucoseTrendSensor(DexcomSensorEntity): """Representation of a Dexcom glucose trend sensor.""" - def __init__(self, coordinator, username): + _attr_translation_key = "glucose_trend" + + def __init__( + self, coordinator: DataUpdateCoordinator, username: str, entry_id: str + ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) - self._attr_name = f"{DOMAIN}_{username}_glucose_trend" - self._attr_unique_id = f"{username}-trend" + super().__init__(coordinator, username, entry_id, "trend") @property def icon(self): diff --git a/homeassistant/components/dexcom/strings.json b/homeassistant/components/dexcom/strings.json index 35d80371c12..7efc2708bcc 100644 --- a/homeassistant/components/dexcom/strings.json +++ b/homeassistant/components/dexcom/strings.json @@ -28,5 +28,15 @@ } } } + }, + "entity": { + "sensor": { + "glucose_value": { + "name": "Glucose value" + }, + "glucose_trend": { + "name": "Glucose trend" + } + } } } diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index b3cfd1b65f2..29b25d0781b 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -415,9 +415,7 @@ class DHCPWatcher(WatcherBase): """Start watching for dhcp packets.""" # Local import because importing from scapy has side effects such as opening # sockets - from scapy import ( # pylint: disable=import-outside-toplevel,unused-import # noqa: F401 - arch, - ) + from scapy import arch # pylint: disable=import-outside-toplevel # noqa: F401 from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 59c6f7961c2..e2bd09ba15e 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -65,7 +65,7 @@ class DigitalOceanBinarySensor(BinarySensorEntity): _attr_attribution = ATTRIBUTION - def __init__(self, do, droplet_id): # pylint: disable=invalid-name + def __init__(self, do, droplet_id): """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 2791d83d6bc..b226dbab0a9 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -63,7 +63,7 @@ class DigitalOceanSwitch(SwitchEntity): _attr_attribution = ATTRIBUTION - def __init__(self, do, droplet_id): # pylint: disable=invalid-name + def __init__(self, do, droplet_id): """Initialize a new Digital Ocean sensor.""" self._digital_ocean = do self._droplet_id = droplet_id diff --git a/homeassistant/components/directv/entity.py b/homeassistant/components/directv/entity.py index 9d1fd68b742..0da2cfcb9d6 100644 --- a/homeassistant/components/directv/entity.py +++ b/homeassistant/components/directv/entity.py @@ -1,11 +1,10 @@ """Base DirecTV Entity.""" from __future__ import annotations -from typing import cast - from directv import DIRECTV -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -13,23 +12,19 @@ from .const import DOMAIN class DIRECTVEntity(Entity): """Defines a base DirecTV entity.""" - def __init__(self, *, dtv: DIRECTV, address: str = "0") -> None: + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: """Initialize the DirecTV entity.""" self._address = address self._device_id = address if address != "0" else dtv.device.info.receiver_id self._is_client = address != "0" self.dtv = dtv - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this DirecTV receiver.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer=self.dtv.device.info.brand, - # Instead of setting the device name to the entity name, directv - # should be updated to set has_entity_name = True, and set the entity - # name to None - name=cast(str | None, self.name), + name=name, sw_version=self.dtv.device.info.version, via_device=(DOMAIN, self.dtv.device.info.receiver_id), ) diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 8c1570db159..63d086564ee 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -80,11 +80,11 @@ class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity): """Initialize DirecTV media player.""" super().__init__( dtv=dtv, + name=name, address=address, ) self._attr_unique_id = self._device_id - self._attr_name = name self._attr_device_class = MediaPlayerDeviceClass.RECEIVER self._attr_available = False diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index c8c84a7f0cc..d100abd3495 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -49,11 +49,11 @@ class DIRECTVRemote(DIRECTVEntity, RemoteEntity): """Initialize DirecTV remote.""" super().__init__( dtv=dtv, + name=name, address=address, ) self._attr_unique_id = self._device_id - self._attr_name = name self._attr_available = False self._attr_is_on = True diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index fe1045203d8..ab892cd9324 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: discovergy_data.meters = await discovergy_data.api_client.meters() except discovergyError.InvalidLogin as err: raise ConfigEntryAuthFailed("Invalid email or password") from err - except Exception as err: # pylint: disable=broad-except + except Exception as err: raise ConfigEntryNotReady( "Unexpected error while while getting meters" ) from err diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 6ee5a4c3e84..d2548d0bacd 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -50,9 +50,9 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): return await self.discovergy_client.meter_last_reading(self.meter.meter_id) except AccessTokenExpired as err: raise ConfigEntryAuthFailed( - f"Auth expired while fetching last reading for meter {self.meter.get_meter_id()}" + f"Auth expired while fetching last reading for meter {self.meter.meter_id}" ) from err except HTTPError as err: raise UpdateFailed( - f"Error while fetching last reading for meter {self.meter.get_meter_id()}" + f"Error while fetching last reading for meter {self.meter.meter_id}" ) from err diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index a7c79bf3b13..e0a9e47e6fd 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -1,20 +1,18 @@ """Diagnostics support for discovergy.""" from __future__ import annotations +from dataclasses import asdict from typing import Any from pydiscovergy.models import Meter from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from . import DiscovergyData from .const import DOMAIN -TO_REDACT_CONFIG_ENTRY = {CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, "title"} - TO_REDACT_METER = { "serial_number", "full_serial_number", @@ -36,14 +34,13 @@ async def async_get_config_entry_diagnostics( for meter in meters: # make a dict of meter data and redact some data - flattened_meter.append(async_redact_data(meter.__dict__, TO_REDACT_METER)) + flattened_meter.append(async_redact_data(asdict(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] = coordinator.data.__dict__ + last_readings[meter.meter_id] = asdict(coordinator.data) return { - "entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY), "meters": flattened_meter, "readings": last_readings, } diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 23d7f1ad5bf..4223318ed93 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/discovergy", - "integration_type": "hub", + "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==2.0.1"] + "requirements": ["pydiscovergy==2.0.3"] } diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 79fc6af1b9a..5b8fb864987 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -1,7 +1,9 @@ """Discovergy sensor entity.""" +from collections.abc import Callable from dataclasses import dataclass, field +from datetime import datetime -from pydiscovergy.models import Meter +from pydiscovergy.models import Meter, Reading from homeassistant.components.sensor import ( SensorDeviceClass, @@ -11,15 +13,15 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + EntityCategory, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DiscovergyData, DiscovergyUpdateCoordinator @@ -32,6 +34,9 @@ PARALLEL_UPDATES = 1 class DiscovergyMixin: """Mixin for alternative keys.""" + value_fn: Callable[[Reading, str, int], datetime | float | None] = field( + default=lambda reading, key, scale: float(reading.values[key] / scale) + ) alternative_keys: list[str] = field(default_factory=lambda: []) scale: int = field(default_factory=lambda: 1000) @@ -144,6 +149,17 @@ ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( ), ) +ADDITIONAL_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( + DiscovergySensorEntityDescription( + key="last_transmitted", + translation_key="last_transmitted", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda reading, key, scale: reading.time_with_timezone, + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -160,18 +176,22 @@ async def async_setup_entry( elif meter.measurement_type == "GAS": sensors = GAS_SENSORS + coordinator: DiscovergyUpdateCoordinator = data.coordinators[meter.meter_id] + if sensors is not None: for description in sensors: # check if this meter has this data, then add this sensor for key in {description.key, *description.alternative_keys}: - coordinator: DiscovergyUpdateCoordinator = data.coordinators[ - meter.meter_id - ] if key in coordinator.data.values: entities.append( DiscovergySensor(key, description, meter, coordinator) ) + for description in ADDITIONAL_SENSORS: + entities.append( + DiscovergySensor(description.key, description, meter, coordinator) + ) + async_add_entities(entities, False) @@ -204,8 +224,8 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt ) @property - def native_value(self) -> StateType: + def native_value(self) -> datetime | float | None: """Return the sensor state.""" - return float( - self.coordinator.data.values[self.data_key] / self.entity_description.scale + return self.entity_description.value_fn( + self.coordinator.data, self.data_key, self.entity_description.scale ) diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index e8dbbab2021..5147440e1b7 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -60,6 +60,9 @@ }, "phase_3_power": { "name": "Phase 3 power" + }, + "last_transmitted": { + "name": "Last transmitted" } } } diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index dcae1c1eb40..42031b28844 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -3,7 +3,7 @@ from __future__ import annotations import io -import face_recognition # pylint: disable=import-error +import face_recognition from homeassistant.components.image_processing import ImageProcessingFaceEntity from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE @@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -# pylint: disable=unused-import from homeassistant.components.image_processing import ( # noqa: F401, isort:skip PLATFORM_SCHEMA, ) diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index 373f2c2b928..e6aaa6848d0 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -4,7 +4,6 @@ from __future__ import annotations import io import logging -# pylint: disable=import-error import face_recognition import voluptuous as vol diff --git a/homeassistant/components/dlink/entity.py b/homeassistant/components/dlink/entity.py index bfe16abd780..238db5f5c57 100644 --- a/homeassistant/components/dlink/entity.py +++ b/homeassistant/components/dlink/entity.py @@ -4,7 +4,8 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from .const import ATTRIBUTION, DOMAIN, MANUFACTURER from .data import SmartPlugData diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 350ea692338..23c45b73ec5 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.34.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.0", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 9aabc3cea5e..2adb2e76347 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.34.1"], + "requirements": ["async-upnp-client==0.35.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index bbc15f1b139..ebe5216ab69 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -11,8 +11,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index deb37c1bfe3..d7800a26fc8 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -5,16 +5,12 @@ from http import HTTPStatus import logging from typing import Any -from aiohttp import web from doorbirdpy import DoorBird import requests -import voluptuous as vol from homeassistant.components import persistent_notification -from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -24,71 +20,25 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt as dt_util, slugify -from .const import ( - CONF_EVENTS, - DOMAIN, - DOOR_STATION, - DOOR_STATION_EVENT_ENTITY_IDS, - DOOR_STATION_INFO, - PLATFORMS, - UNDO_UPDATE_LISTENER, -) -from .util import get_doorstation_by_token +from .const import CONF_EVENTS, DOMAIN, PLATFORMS +from .device import ConfiguredDoorBird +from .models import DoorBirdData +from .view import DoorBirdRequestView _LOGGER = logging.getLogger(__name__) -API_URL = f"/api/{DOMAIN}" - CONF_CUSTOM_URL = "hass_url_override" -RESET_DEVICE_FAVORITES = "doorbird_reset_favorites" - -DEVICE_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_TOKEN): cv.string, - vol.Optional(CONF_EVENTS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CUSTOM_URL): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the DoorBird component.""" hass.data.setdefault(DOMAIN, {}) - - # Provide an endpoint for the doorstations to call to trigger events + # Provide an endpoint for the door stations to call to trigger events hass.http.register_view(DoorBirdRequestView) - - def _reset_device_favorites_handler(event): - """Handle clearing favorites on device.""" - if (token := event.data.get("token")) is None: - return - - doorstation = get_doorstation_by_token(hass, token) - - if doorstation is None: - _LOGGER.error("Device not found for provided token") - return - - # Clear webhooks - favorites = doorstation.device.favorites() - - for favorite_type in favorites: - for favorite_id in favorites[favorite_type]: - doorstation.device.delete_favorite(favorite_type, favorite_id) - - hass.bus.async_listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler) - return True @@ -97,17 +47,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _async_import_options_from_data_if_missing(hass, entry) - doorstation_config = entry.data - doorstation_options = entry.options + door_station_config = entry.data config_entry_id = entry.entry_id - device_ip = doorstation_config[CONF_HOST] - username = doorstation_config[CONF_USERNAME] - password = doorstation_config[CONF_PASSWORD] + device_ip = door_station_config[CONF_HOST] + username = door_station_config[CONF_USERNAME] + password = door_station_config[CONF_PASSWORD] device = DoorBird(device_ip, username, password) try: - status, info = await hass.async_add_executor_job(_init_doorbird_device, device) + status, info = await hass.async_add_executor_job(_init_door_bird_device, device) except requests.exceptions.HTTPError as err: if err.response.status_code == HTTPStatus.UNAUTHORIZED: _LOGGER.error( @@ -128,50 +77,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryNotReady - token = doorstation_config.get(CONF_TOKEN, config_entry_id) - custom_url = doorstation_config.get(CONF_CUSTOM_URL) - name = doorstation_config.get(CONF_NAME) - events = doorstation_options.get(CONF_EVENTS, []) - doorstation = ConfiguredDoorBird(device, name, custom_url, token) - doorstation.update_events(events) + token: str = door_station_config.get(CONF_TOKEN, config_entry_id) + custom_url: str | None = door_station_config.get(CONF_CUSTOM_URL) + name: str | None = door_station_config.get(CONF_NAME) + events = entry.options.get(CONF_EVENTS, []) + event_entity_ids: dict[str, str] = {} + door_station = ConfiguredDoorBird(device, name, custom_url, token, event_entity_ids) + door_bird_data = DoorBirdData(door_station, info, event_entity_ids) + door_station.update_events(events) # Subscribe to doorbell or motion events - if not await _async_register_events(hass, doorstation): + if not await _async_register_events(hass, door_station): raise ConfigEntryNotReady - undo_listener = entry.add_update_listener(_update_listener) - - hass.data[DOMAIN][config_entry_id] = { - DOOR_STATION: doorstation, - DOOR_STATION_INFO: info, - UNDO_UPDATE_LISTENER: undo_listener, - } - + entry.async_on_unload(entry.add_update_listener(_update_listener)) + hass.data[DOMAIN][config_entry_id] = door_bird_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -def _init_doorbird_device(device): +def _init_door_bird_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: + """Verify we can connect to the device and return the status.""" return device.ready(), device.info() async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - + data: dict[str, DoorBirdData] = hass.data[DOMAIN] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + data.pop(entry.entry_id) return unload_ok async def _async_register_events( - hass: HomeAssistant, doorstation: ConfiguredDoorBird + hass: HomeAssistant, door_station: ConfiguredDoorBird ) -> bool: + """Register events on device.""" try: - await hass.async_add_executor_job(doorstation.register_events, hass) + await hass.async_add_executor_job(door_station.register_events, hass) except requests.exceptions.HTTPError: persistent_notification.async_create( hass, @@ -192,14 +135,17 @@ async def _async_register_events( async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" config_entry_id = entry.entry_id - doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] - doorstation.update_events(entry.options[CONF_EVENTS]) + data: DoorBirdData = hass.data[DOMAIN][config_entry_id] + door_station = data.door_station + door_station.update_events(entry.options[CONF_EVENTS]) # Subscribe to doorbell or motion events - await _async_register_events(hass, doorstation) + await _async_register_events(hass, door_station) @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 = False for importable_option in (CONF_EVENTS,): @@ -209,160 +155,3 @@ def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: Confi if modified: hass.config_entries.async_update_entry(entry, options=options) - - -class ConfiguredDoorBird: - """Attach additional information to pass along with configured device.""" - - def __init__(self, device, name, custom_url, token): - """Initialize configured device.""" - self._name = name - self._device = device - self._custom_url = custom_url - self.events = None - self.doorstation_events = None - self._token = token - - def update_events(self, events): - """Update the doorbird events.""" - self.events = events - self.doorstation_events = [self._get_event_name(event) for event in self.events] - - @property - def name(self): - """Get custom device name.""" - return self._name - - @property - def device(self): - """Get the configured device.""" - return self._device - - @property - def custom_url(self): - """Get custom url for device.""" - return self._custom_url - - @property - def token(self): - """Get token for device.""" - return self._token - - def register_events(self, hass: HomeAssistant) -> None: - """Register events on device.""" - # Get the URL of this server - hass_url = get_url(hass, prefer_external=False) - - # Override url if another is specified in the configuration - if self.custom_url is not None: - hass_url = self.custom_url - - if not self.doorstation_events: - # User may not have permission to get the favorites - return - - favorites = self.device.favorites() - for event in self.doorstation_events: - if self._register_event(hass_url, event, favs=favorites): - _LOGGER.info( - "Successfully registered URL for %s on %s", event, self.name - ) - - @property - def slug(self): - """Get device slug.""" - return slugify(self._name) - - def _get_event_name(self, event): - return f"{self.slug}_{event}" - - def _register_event( - self, hass_url: str, event: str, favs: dict[str, Any] | None = None - ) -> bool: - """Add a schedule entry in the device for a sensor.""" - url = f"{hass_url}{API_URL}/{event}?token={self._token}" - - # Register HA URL as webhook if not already, then get the ID - if self.webhook_is_registered(url, favs=favs): - return True - - self.device.change_favorite("http", f"Home Assistant ({event})", url) - if not self.webhook_is_registered(url): - _LOGGER.warning( - 'Unable to set favorite URL "%s". Event "%s" will not fire', - url, - event, - ) - return False - return True - - def webhook_is_registered(self, url, favs=None) -> bool: - """Return whether the given URL is registered as a device favorite.""" - return self.get_webhook_id(url, favs) is not None - - def get_webhook_id(self, url, favs=None) -> str | None: - """Return the device favorite ID for the given URL. - - The favorite must exist or there will be problems. - """ - favs = favs if favs else self.device.favorites() - - if "http" not in favs: - return None - - for fav_id in favs["http"]: - if favs["http"][fav_id]["value"] == url: - return fav_id - - return None - - def get_event_data(self): - """Get data to pass along with HA event.""" - return { - "timestamp": dt_util.utcnow().isoformat(), - "live_video_url": self._device.live_video_url, - "live_image_url": self._device.live_image_url, - "rtsp_live_video_url": self._device.rtsp_live_video_url, - "html5_viewer_url": self._device.html5_viewer_url, - } - - -class DoorBirdRequestView(HomeAssistantView): - """Provide a page for the device to call.""" - - requires_auth = False - url = API_URL - name = API_URL[1:].replace("/", ":") - extra_urls = [API_URL + "/{event}"] - - async def get(self, request, event): - """Respond to requests from the device.""" - hass = request.app["hass"] - - token = request.query.get("token") - - device = get_doorstation_by_token(hass, token) - - if device is None: - return web.Response( - status=HTTPStatus.UNAUTHORIZED, text="Invalid token provided." - ) - - if device: - event_data = device.get_event_data() - else: - event_data = {} - - if event == "clear": - hass.bus.async_fire(RESET_DEVICE_FAVORITES, {"token": token}) - - message = f"HTTP Favorites cleared for {device.slug}" - return web.Response(text=message) - - event_data[ATTR_ENTITY_ID] = hass.data[DOMAIN][ - DOOR_STATION_EVENT_ENTITY_IDS - ].get(event) - - hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) - - return web.Response(text="OK") diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index ad1356023fc..1c69429d3c7 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -10,8 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO +from .const import DOMAIN from .entity import DoorBirdEntity +from .models import DoorBirdData IR_RELAY = "__ir_light__" @@ -49,20 +50,14 @@ async def async_setup_entry( ) -> None: """Set up the DoorBird button platform.""" config_entry_id = config_entry.entry_id - - data = hass.data[DOMAIN][config_entry_id] - doorstation = data[DOOR_STATION] - doorstation_info = data[DOOR_STATION_INFO] - - relays = doorstation_info["RELAYS"] + door_bird_data: DoorBirdData = hass.data[DOMAIN][config_entry_id] + relays = door_bird_data.door_station_info["RELAYS"] entities = [ - DoorBirdButton(doorstation, doorstation_info, relay, RELAY_ENTITY_DESCRIPTION) + DoorBirdButton(door_bird_data, relay, RELAY_ENTITY_DESCRIPTION) for relay in relays ] - entities.append( - DoorBirdButton(doorstation, doorstation_info, IR_RELAY, IR_ENTITY_DESCRIPTION) - ) + entities.append(DoorBirdButton(door_bird_data, IR_RELAY, IR_ENTITY_DESCRIPTION)) async_add_entities(entities) @@ -74,22 +69,20 @@ class DoorBirdButton(DoorBirdEntity, ButtonEntity): def __init__( self, - doorstation: DoorBird, - doorstation_info, + door_bird_data: DoorBirdData, relay: str, entity_description: DoorbirdButtonEntityDescription, ) -> None: """Initialize a relay in a DoorBird device.""" - super().__init__(doorstation, doorstation_info) + super().__init__(door_bird_data) self._relay = relay self.entity_description = entity_description - if self._relay == IR_RELAY: - self._attr_name = f"{self._doorstation.name} IR" + self._attr_name = "IR" else: - self._attr_name = f"{self._doorstation.name} Relay {self._relay}" + self._attr_name = f"Relay {self._relay}" self._attr_unique_id = f"{self._mac_addr}_{self._relay}" def press(self) -> None: """Power the relay.""" - self.entity_description.press_action(self._doorstation.device, self._relay) + self.entity_description.press_action(self._door_station.device, self._relay) diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 5983e639851..a4133f2da2c 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -6,7 +6,6 @@ import datetime import logging import aiohttp -import async_timeout from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry @@ -15,13 +14,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import ( - DOMAIN, - DOOR_STATION, - DOOR_STATION_EVENT_ENTITY_IDS, - DOOR_STATION_INFO, -) +from .const import DOMAIN from .entity import DoorBirdEntity +from .models import DoorBirdData _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=2) _LAST_MOTION_INTERVAL = datetime.timedelta(seconds=30) @@ -37,39 +32,31 @@ async def async_setup_entry( ) -> None: """Set up the DoorBird camera platform.""" config_entry_id = config_entry.entry_id - config_data = hass.data[DOMAIN][config_entry_id] - doorstation = config_data[DOOR_STATION] - doorstation_info = config_data[DOOR_STATION_INFO] - device = doorstation.device + door_bird_data: DoorBirdData = hass.data[DOMAIN][config_entry_id] + device = door_bird_data.door_station.device async_add_entities( [ DoorBirdCamera( - doorstation, - doorstation_info, + door_bird_data, device.live_image_url, "live", - f"{doorstation.name} Live", - doorstation.doorstation_events, + "live", _LIVE_INTERVAL, device.rtsp_live_video_url, ), DoorBirdCamera( - doorstation, - doorstation_info, + door_bird_data, device.history_image_url(1, "doorbell"), "last_ring", - f"{doorstation.name} Last Ring", - [], + "last_ring", _LAST_VISITOR_INTERVAL, ), DoorBirdCamera( - doorstation, - doorstation_info, + door_bird_data, device.history_image_url(1, "motionsensor"), "last_motion", - f"{doorstation.name} Last Motion", - [], + "last_motion", _LAST_MOTION_INTERVAL, ), ] @@ -81,29 +68,26 @@ class DoorBirdCamera(DoorBirdEntity, Camera): def __init__( self, - doorstation, - doorstation_info, - url, - camera_id, - name, - doorstation_events, - interval, - stream_url=None, + door_bird_data: DoorBirdData, + url: str, + camera_id: str, + translation_key: str, + interval: datetime.timedelta, + stream_url: str | None = None, ) -> None: """Initialize the camera on a DoorBird device.""" - super().__init__(doorstation, doorstation_info) + super().__init__(door_bird_data) self._url = url self._stream_url = stream_url - self._attr_name = name + self._attr_translation_key = translation_key self._last_image: bytes | None = None if self._stream_url: self._attr_supported_features = CameraEntityFeature.STREAM self._interval = interval self._last_update = datetime.datetime.min self._attr_unique_id = f"{self._mac_addr}_{camera_id}" - self._doorstation_events = doorstation_events - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the stream source.""" return self._stream_url @@ -118,7 +102,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera): try: websession = async_get_clientsession(self.hass) - async with async_timeout.timeout(_TIMEOUT): + async with asyncio.timeout(_TIMEOUT): response = await websession.get(self._url) self._last_image = await response.read() @@ -134,19 +118,16 @@ class DoorBirdCamera(DoorBirdEntity, Camera): return self._last_image async def async_added_to_hass(self) -> None: - """Add callback after being added to hass. - - Registers entity_id map for the logbook - """ - event_to_entity_id = self.hass.data[DOMAIN].setdefault( - DOOR_STATION_EVENT_ENTITY_IDS, {} - ) - for event in self._doorstation_events: + """Subscribe to events.""" + await super().async_added_to_hass() + event_to_entity_id = self._door_bird_data.event_entity_ids + for event in self._door_station.events: event_to_entity_id[event] = self.entity_id - async def will_remove_from_hass(self): - """Unregister entity_id map for the logbook.""" - event_to_entity_id = self.hass.data[DOMAIN][DOOR_STATION_EVENT_ENTITY_IDS] - for event in self._doorstation_events: - if event in event_to_entity_id: - del event_to_entity_id[event] + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from events.""" + event_to_entity_id = self._door_bird_data.event_entity_ids + for event in self._door_station.events: + # If the clear api was called, the events may not be in the dict + event_to_entity_id.pop(event, None) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 4ad5e24247e..56a02f49042 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from http import HTTPStatus from ipaddress import ip_address import logging +from typing import Any from doorbirdpy import DoorBird import requests @@ -12,17 +13,19 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.util.network import is_ipv4_address, is_link_local from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI -from .util import get_mac_address_from_doorstation_info +from .util import get_mac_address_from_door_station_info _LOGGER = logging.getLogger(__name__) -def _schema_with_defaults(host=None, name=None): +def _schema_with_defaults( + host: str | None = None, name: str | None = None +) -> vol.Schema: return vol.Schema( { vol.Required(CONF_HOST, default=host): str, @@ -33,12 +36,14 @@ def _schema_with_defaults(host=None, name=None): ) -def _check_device(device): +def _check_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: """Verify we can connect to the device and return the status.""" return device.ready(), device.info() -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" device = DoorBird(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) try: @@ -53,13 +58,13 @@ async def validate_input(hass: core.HomeAssistant, data): if not status[0]: raise CannotConnect - mac_addr = get_mac_address_from_doorstation_info(info) + mac_addr = get_mac_address_from_door_station_info(info) # Return info that you want to store in the config entry. return {"title": data[CONF_HOST], "mac_addr": mac_addr} -async def async_verify_supported_device(hass, host): +async def async_verify_supported_device(hass: HomeAssistant, host: str) -> bool: """Verify the doorbell state endpoint returns a 401.""" device = DoorBird(host, "", "") try: @@ -77,13 +82,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the DoorBird config flow.""" - self.discovery_schema = {} + self.discovery_schema: vol.Schema | 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: info, errors = await self._async_validate_or_error(user_input) if not errors: @@ -127,7 +134,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def _async_validate_or_error(self, user_input): + async def _async_validate_or_error( + self, user_input: dict[str, Any] + ) -> tuple[dict[str, Any], dict[str, Any]]: """Validate doorbird or error.""" errors = {} info = {} @@ -158,7 +167,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: events = [event.strip() for event in user_input[CONF_EVENTS].split(",")] diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py index 767366af734..416603a312c 100644 --- a/homeassistant/components/doorbird/const.py +++ b/homeassistant/components/doorbird/const.py @@ -19,3 +19,5 @@ DOORBIRD_INFO_KEY_PRIMARY_MAC_ADDR = "PRIMARY_MAC_ADDR" DOORBIRD_INFO_KEY_WIFI_MAC_ADDR = "WIFI_MAC_ADDR" UNDO_UPDATE_LISTENER = "undo_update_listener" + +API_URL = f"/api/{DOMAIN}" diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py new file mode 100644 index 00000000000..767a80a7857 --- /dev/null +++ b/homeassistant/components/doorbird/device.py @@ -0,0 +1,164 @@ +"""Support for DoorBird devices.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from doorbirdpy import DoorBird + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import get_url +from homeassistant.util import dt as dt_util, slugify + +from .const import API_URL + +_LOGGER = logging.getLogger(__name__) + + +class ConfiguredDoorBird: + """Attach additional information to pass along with configured device.""" + + def __init__( + self, + device: DoorBird, + name: str | None, + custom_url: str | None, + token: str, + event_entity_ids: dict[str, str], + ) -> None: + """Initialize configured device.""" + self._name = name + self._device = device + self._custom_url = custom_url + self._token = token + self._event_entity_ids = event_entity_ids + self.events: list[str] = [] + self.door_station_events: list[str] = [] + + def update_events(self, events: list[str]) -> None: + """Update the doorbird events.""" + self.events = events + self.door_station_events = [ + self._get_event_name(event) for event in self.events + ] + + @property + def name(self) -> str | None: + """Get custom device name.""" + return self._name + + @property + def device(self) -> DoorBird: + """Get the configured device.""" + return self._device + + @property + def custom_url(self) -> str | None: + """Get custom url for device.""" + return self._custom_url + + @property + def token(self) -> str: + """Get token for device.""" + return self._token + + def register_events(self, hass: HomeAssistant) -> None: + """Register events on device.""" + # Get the URL of this server + hass_url = get_url(hass, prefer_external=False) + + # Override url if another is specified in the configuration + if self.custom_url is not None: + hass_url = self.custom_url + + if not self.door_station_events: + # User may not have permission to get the favorites + return + + favorites = self.device.favorites() + for event in self.door_station_events: + if self._register_event(hass_url, event, favs=favorites): + _LOGGER.info( + "Successfully registered URL for %s on %s", event, self.name + ) + + @property + def slug(self) -> str: + """Get device slug.""" + return slugify(self._name) + + def _get_event_name(self, event: str) -> str: + return f"{self.slug}_{event}" + + def _register_event( + self, hass_url: str, event: str, favs: dict[str, Any] | None = None + ) -> bool: + """Add a schedule entry in the device for a sensor.""" + url = f"{hass_url}{API_URL}/{event}?token={self._token}" + + # Register HA URL as webhook if not already, then get the ID + if self.webhook_is_registered(url, favs=favs): + return True + + self.device.change_favorite("http", f"Home Assistant ({event})", url) + if not self.webhook_is_registered(url): + _LOGGER.warning( + 'Unable to set favorite URL "%s". Event "%s" will not fire', + url, + event, + ) + return False + return True + + def webhook_is_registered( + self, url: str, favs: dict[str, Any] | None = None + ) -> bool: + """Return whether the given URL is registered as a device favorite.""" + return self.get_webhook_id(url, favs) is not None + + def get_webhook_id( + self, url: str, favs: dict[str, Any] | None = None + ) -> str | None: + """Return the device favorite ID for the given URL. + + The favorite must exist or there will be problems. + """ + favs = favs if favs else self.device.favorites() + + if "http" not in favs: + return None + + for fav_id in favs["http"]: + if favs["http"][fav_id]["value"] == url: + return cast(str, fav_id) + + return None + + def get_event_data(self, event: str) -> dict[str, str | None]: + """Get data to pass along with HA event.""" + return { + "timestamp": dt_util.utcnow().isoformat(), + "live_video_url": self._device.live_video_url, + "live_image_url": self._device.live_image_url, + "rtsp_live_video_url": self._device.rtsp_live_video_url, + "html5_viewer_url": self._device.html5_viewer_url, + ATTR_ENTITY_ID: self._event_entity_ids.get(event), + } + + +async def async_reset_device_favorites( + hass: HomeAssistant, door_station: ConfiguredDoorBird +) -> None: + """Handle clearing favorites on device.""" + await hass.async_add_executor_job(_reset_device_favorites, door_station) + + +def _reset_device_favorites(door_station: ConfiguredDoorBird) -> None: + """Handle clearing favorites on device.""" + # Clear webhooks + door_bird = door_station.device + favorites: dict[str, list[str]] = door_bird.favorites() + for favorite_type, favorite_ids in favorites.items(): + for favorite_id in favorite_ids: + door_bird.delete_favorite(favorite_type, favorite_id) diff --git a/homeassistant/components/doorbird/entity.py b/homeassistant/components/doorbird/entity.py index 4247015a3b0..4360a8ff490 100644 --- a/homeassistant/components/doorbird/entity.py +++ b/homeassistant/components/doorbird/entity.py @@ -1,7 +1,9 @@ """The DoorBird integration base entity.""" + from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import ( DOORBIRD_INFO_KEY_BUILD_NUMBER, @@ -9,25 +11,29 @@ from .const import ( DOORBIRD_INFO_KEY_FIRMWARE, MANUFACTURER, ) -from .util import get_mac_address_from_doorstation_info +from .models import DoorBirdData +from .util import get_mac_address_from_door_station_info class DoorBirdEntity(Entity): """Base class for doorbird entities.""" - def __init__(self, doorstation, doorstation_info): + _attr_has_entity_name = True + + def __init__(self, door_bird_data: DoorBirdData) -> None: """Initialize the entity.""" super().__init__() - self._doorstation = doorstation - self._mac_addr = get_mac_address_from_doorstation_info(doorstation_info) - - firmware = doorstation_info[DOORBIRD_INFO_KEY_FIRMWARE] - firmware_build = doorstation_info[DOORBIRD_INFO_KEY_BUILD_NUMBER] + self._door_bird_data = door_bird_data + self._door_station = door_bird_data.door_station + door_station_info = door_bird_data.door_station_info + self._mac_addr = get_mac_address_from_door_station_info(door_station_info) + firmware = door_station_info[DOORBIRD_INFO_KEY_FIRMWARE] + firmware_build = door_station_info[DOORBIRD_INFO_KEY_BUILD_NUMBER] self._attr_device_info = DeviceInfo( configuration_url="https://webadmin.doorbird.com/", connections={(dr.CONNECTION_NETWORK_MAC, self._mac_addr)}, manufacturer=MANUFACTURER, - model=doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE], - name=self._doorstation.name, + model=door_station_info[DOORBIRD_INFO_KEY_DEVICE_TYPE], + name=self._door_station.name, sw_version=f"{firmware} {firmware_build}", ) diff --git a/homeassistant/components/doorbird/logbook.py b/homeassistant/components/doorbird/logbook.py index f3beebe6971..84497a312ae 100644 --- a/homeassistant/components/doorbird/logbook.py +++ b/homeassistant/components/doorbird/logbook.py @@ -1,7 +1,7 @@ """Describe logbook events.""" from __future__ import annotations -from typing import Any +from collections.abc import Callable from homeassistant.components.logbook import ( LOGBOOK_ENTRY_ENTITY_ID, @@ -9,35 +9,34 @@ from homeassistant.components.logbook import ( LOGBOOK_ENTRY_NAME, ) from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import callback +from homeassistant.core import Event, HomeAssistant, callback -from .const import DOMAIN, DOOR_STATION, DOOR_STATION_EVENT_ENTITY_IDS +from .const import DOMAIN +from .models import DoorBirdData @callback -def async_describe_events(hass, async_describe_event): +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[ + [str, str, Callable[[Event], dict[str, str | None]]], None + ], +) -> None: """Describe logbook events.""" @callback - def async_describe_logbook_event(event): + def async_describe_logbook_event(event: Event) -> dict[str, str | None]: """Describe a logbook event.""" - doorbird_event = event.event_type.split("_", 1)[1] - return { LOGBOOK_ENTRY_NAME: "Doorbird", LOGBOOK_ENTRY_MESSAGE: f"Event {event.event_type} was fired", - LOGBOOK_ENTRY_ENTITY_ID: hass.data[DOMAIN][ - DOOR_STATION_EVENT_ENTITY_IDS - ].get(doorbird_event, event.data.get(ATTR_ENTITY_ID)), + # Database entries before Jun 25th 2020 will not have an entity ID + LOGBOOK_ENTRY_ENTITY_ID: event.data.get(ATTR_ENTITY_ID), } - domain_data: dict[str, Any] = hass.data[DOMAIN] - + domain_data: dict[str, DoorBirdData] = hass.data[DOMAIN] for data in domain_data.values(): - if DOOR_STATION not in data: - # We need to skip door_station_event_entity_ids - continue - for event in data[DOOR_STATION].doorstation_events: + for event in data.door_station.door_station_events: async_describe_event( DOMAIN, f"{DOMAIN}_{event}", async_describe_logbook_event ) diff --git a/homeassistant/components/doorbird/models.py b/homeassistant/components/doorbird/models.py new file mode 100644 index 00000000000..f8fb8687e59 --- /dev/null +++ b/homeassistant/components/doorbird/models.py @@ -0,0 +1,26 @@ +"""The doorbird integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .device import ConfiguredDoorBird + + +@dataclass +class DoorBirdData: + """Data for the doorbird integration.""" + + door_station: ConfiguredDoorBird + door_station_info: dict[str, Any] + + # + # This integration uses a different event for + # each entity id. It would be a major breaking + # change to change this to a single event at this + # point. + # + # Do not copy this pattern in the future + # for any new integrations. + # + event_entity_ids: dict[str, str] diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 44fd07c405e..ceaf1a891ee 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -33,5 +33,18 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "camera": { + "live": { + "name": "live" + }, + "last_ring": { + "name": "Last ring" + }, + "last_motion": { + "name": "Last motion" + } + } } } diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py index 55974bc1866..b3b62a4985a 100644 --- a/homeassistant/components/doorbird/util.py +++ b/homeassistant/components/doorbird/util.py @@ -1,42 +1,26 @@ """DoorBird integration utils.""" -from .const import DOMAIN, DOOR_STATION +from typing import Any + +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .device import ConfiguredDoorBird +from .models import DoorBirdData -def get_mac_address_from_doorstation_info(doorstation_info): +def get_mac_address_from_door_station_info(door_station_info: dict[str, Any]) -> str: """Get the mac address depending on the device type.""" - if "PRIMARY_MAC_ADDR" in doorstation_info: - return doorstation_info["PRIMARY_MAC_ADDR"] - return doorstation_info["WIFI_MAC_ADDR"] + return door_station_info.get("PRIMARY_MAC_ADDR", door_station_info["WIFI_MAC_ADDR"]) -def get_doorstation_by_token(hass, token): - """Get doorstation by token.""" - return _get_doorstation_by_attr(hass, "token", token) - - -def get_doorstation_by_slug(hass, slug): - """Get doorstation by slug.""" - return _get_doorstation_by_attr(hass, "slug", slug) - - -def _get_doorstation_by_attr(hass, attr, val): - for entry in hass.data[DOMAIN].values(): - if DOOR_STATION not in entry: - continue - - doorstation = entry[DOOR_STATION] - - if getattr(doorstation, attr) == val: - return doorstation - +def get_door_station_by_token( + hass: HomeAssistant, token: str +) -> ConfiguredDoorBird | None: + """Get door station by token.""" + domain_data: dict[str, DoorBirdData] = hass.data[DOMAIN] + for data in domain_data.values(): + door_station = data.door_station + if door_station.token == token: + return door_station return None - - -def get_all_doorstations(hass): - """Get all doorstations.""" - return [ - entry[DOOR_STATION] - for entry in hass.data[DOMAIN].values() - if DOOR_STATION in entry - ] diff --git a/homeassistant/components/doorbird/view.py b/homeassistant/components/doorbird/view.py new file mode 100644 index 00000000000..396db79bf4c --- /dev/null +++ b/homeassistant/components/doorbird/view.py @@ -0,0 +1,55 @@ +"""Support for DoorBird devices.""" +from __future__ import annotations + +from http import HTTPStatus + +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant + +from .const import API_URL, DOMAIN +from .device import async_reset_device_favorites +from .util import get_door_station_by_token + + +class DoorBirdRequestView(HomeAssistantView): + """Provide a page for the device to call.""" + + requires_auth = False + url = API_URL + name = API_URL[1:].replace("/", ":") + extra_urls = [API_URL + "/{event}"] + + async def get(self, request: web.Request, event: str) -> web.Response: + """Respond to requests from the device.""" + hass: HomeAssistant = request.app["hass"] + token: str | None = request.query.get("token") + if ( + token is None + or (door_station := get_door_station_by_token(hass, token)) is None + ): + return web.Response( + status=HTTPStatus.UNAUTHORIZED, text="Invalid token provided." + ) + + if door_station: + event_data = door_station.get_event_data(event) + else: + event_data = {} + + if event == "clear": + await async_reset_device_favorites(hass, door_station) + message = f"HTTP Favorites cleared for {door_station.slug}" + return web.Response(text=message) + + # + # This integration uses a multiple different events. + # It would be a major breaking change to change this to + # a single event at this point. + # + # Do not copy this pattern in the future + # for any new integrations. + # + hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) + return web.Response(text="OK") diff --git a/homeassistant/components/dormakaba_dkey/entity.py b/homeassistant/components/dormakaba_dkey/entity.py index 9ec2720d1e8..26a06deed0e 100644 --- a/homeassistant/components/dormakaba_dkey/entity.py +++ b/homeassistant/components/dormakaba_dkey/entity.py @@ -8,7 +8,7 @@ from py_dormakaba_dkey.commands import Notifications from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/dremel_3d_printer/entity.py b/homeassistant/components/dremel_3d_printer/entity.py index 392869a138b..46686e47e1f 100644 --- a/homeassistant/components/dremel_3d_printer/entity.py +++ b/homeassistant/components/dremel_3d_printer/entity.py @@ -2,7 +2,8 @@ from dremel3dpy import Dremel3DPrinter -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 6152a3756e3..c7b9ab4e380 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -6,7 +6,6 @@ from functools import partial import os from typing import Any -from async_timeout import timeout from dsmr_parser import obis_references as obis_ref from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader from dsmr_parser.clients.rfxtrx_protocol import ( @@ -121,7 +120,7 @@ class DSMRConnection: if transport: try: - async with timeout(30): + async with asyncio.timeout(30): await protocol.wait_closed() except asyncio.TimeoutError: # Timeout (no data received), close transport and return True (if telegram is empty, will result in CannotCommunicate error) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 12ad3350e44..e4f9d0e9ab9 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -33,7 +33,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import CoreState, Event, HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle @@ -509,7 +509,7 @@ async def async_setup_entry( if stop_listener and ( hass.state == CoreState.not_running or hass.is_running ): - stop_listener() # pylint: disable=not-callable + stop_listener() if transport: transport.close() diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 278c3c989db..d477bd41a26 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -138,6 +138,6 @@ def async_track_time_interval_backoff( def remove_listener() -> None: """Remove interval listener.""" if remove: - remove() # pylint: disable=not-callable + remove() return remove_listener diff --git a/homeassistant/components/dunehd/manifest.json b/homeassistant/components/dunehd/manifest.json index f0d4d71ed0d..b5528e0f565 100644 --- a/homeassistant/components/dunehd/manifest.json +++ b/homeassistant/components/dunehd/manifest.json @@ -1,7 +1,7 @@ { "domain": "dunehd", "name": "Dune HD", - "codeowners": ["@bieniu"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dunehd", "iot_class": "local_polling", diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index 367eb6cb296..4f6bf6fb677 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -12,7 +12,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN @@ -49,10 +49,14 @@ class DuneHDPlayerEntity(MediaPlayerEntity): def __init__(self, player: DuneHDPlayer, name: str, unique_id: str) -> None: """Initialize entity to control Dune HD.""" self._player = player - self._name = name self._media_title: str | None = None self._state: dict[str, Any] = {} - self._unique_id = unique_id + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + name=name, + ) def update(self) -> None: """Update internal status of the entity.""" @@ -78,20 +82,6 @@ class DuneHDPlayerEntity(MediaPlayerEntity): """Return True if entity is available.""" return len(self._state) > 0 - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._unique_id)}, - manufacturer=ATTR_MANUFACTURER, - name=self._name, - ) - @property def volume_level(self) -> float: """Return the volume level of the media player (0..1).""" diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index 98003c3e8c4..4c8060b468d 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.COVER] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py index 0fd212df085..0be9daf572b 100644 --- a/homeassistant/components/duotecno/cover.py +++ b/homeassistant/components/duotecno/cover.py @@ -5,17 +5,13 @@ from typing import Any from duotecno.unit import DuoswitchUnit -from homeassistant.components.cover import ( - CoverEntity, - CoverEntityFeature, -) +from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import DuotecnoEntity +from .entity import DuotecnoEntity, api_call async def async_setup_entry( @@ -57,29 +53,17 @@ class DuotecnoCover(DuotecnoEntity, CoverEntity): """Return if the cover is closing.""" return self._unit.is_closing() + @api_call async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - try: - await self._unit.open() - except OSError as err: - raise HomeAssistantError( - "Transmit for the open_cover packet failed" - ) from err + await self._unit.open() + @api_call async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - try: - await self._unit.close() - except OSError as err: - raise HomeAssistantError( - "Transmit for the close_cover packet failed" - ) from err + await self._unit.close() + @api_call async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - try: - await self._unit.stop() - except OSError as err: - raise HomeAssistantError( - "Transmit for the stop_cover packet failed" - ) from err + await self._unit.stop() diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index f1c72aa55c4..d38d52a0d26 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -1,9 +1,15 @@ """Support for Velbus devices.""" from __future__ import annotations +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate, ParamSpec, TypeVar + from duotecno.unit import BaseUnit -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -34,3 +40,25 @@ class DuotecnoEntity(Entity): async def _on_update(self) -> None: """When a unit has an update.""" self.async_write_ha_state() + + +_T = TypeVar("_T", bound="DuotecnoEntity") +_P = ParamSpec("_P") + + +def api_call( + func: Callable[Concatenate[_T, _P], Awaitable[None]] +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch command exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except OSError as exc: + raise HomeAssistantError( + f"Error calling {func.__name__} on entity {self.entity_id}" + ) from exc + + return cmd_wrapper diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py new file mode 100644 index 00000000000..9aee4513fca --- /dev/null +++ b/homeassistant/components/duotecno/light.py @@ -0,0 +1,56 @@ +"""Support for Duotecno lights.""" +from typing import Any + +from duotecno.unit import DimUnit + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +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 DuotecnoEntity, api_call + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Duotecno light based on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities(DuotecnoLight(channel) for channel in cntrl.get_units("DimUnit")) + + +class DuotecnoLight(DuotecnoEntity, LightEntity): + """Representation of a light.""" + + _unit: DimUnit + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + @property + def is_on(self) -> bool: + """Return true if the light is on.""" + return self._unit.is_on() + + @property + def brightness(self) -> int: + """Return the brightness of the light.""" + return int((self._unit.get_dimmer_state() * 255) / 100) + + @api_call + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + if (val := kwargs.get(ATTR_BRIGHTNESS)) is not None: + # set to a value + val = max(int((val * 100) / 255), 1) + else: + # restore state + val = None + await self._unit.set_dimmer_state(val) + + @api_call + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + await self._unit.set_dimmer_state(0) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 69490b6b5aa..d26d4fce61e 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", - "requirements": ["pyduotecno==2023.8.3"] + "requirements": ["pyduotecno==2023.8.4"] } diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py index a9921de85d3..63bab750543 100644 --- a/homeassistant/components/duotecno/switch.py +++ b/homeassistant/components/duotecno/switch.py @@ -6,11 +6,10 @@ from duotecno.unit import SwitchUnit from homeassistant.components.switch import SwitchEntity 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 .entity import DuotecnoEntity +from .entity import DuotecnoEntity, api_call async def async_setup_entry( @@ -35,16 +34,12 @@ class DuotecnoSwitch(DuotecnoEntity, SwitchEntity): """Return true if the switch is on.""" return self._unit.is_on() + @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the switch to turn on.""" - try: - await self._unit.turn_on() - except OSError as err: - raise HomeAssistantError("Transmit for the turn_on packet failed") from err + await self._unit.turn_on() + @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the switch to turn off.""" - try: - await self._unit.turn_off() - except OSError as err: - raise HomeAssistantError("Transmit for the turn_off packet failed") from err + await self._unit.turn_off() diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index a383e33eab2..dab3a39c10f 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,7 +1,7 @@ { "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], + "codeowners": ["@runningman84", "@stephan192", "@andarotajo"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "iot_class": "cloud_polling", diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 62bb4af7930..78154e9e4f4 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -24,6 +24,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -55,12 +56,12 @@ from .coordinator import DwdWeatherWarningsCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=CURRENT_WARNING_SENSOR, - name="Current Warning Level", + translation_key=CURRENT_WARNING_SENSOR, icon="mdi:close-octagon-outline", ), SensorEntityDescription( key=ADVANCE_WARNING_SENSOR, - name="Advance Warning Level", + translation_key=ADVANCE_WARNING_SENSOR, icon="mdi:close-octagon-outline", ), ) @@ -130,6 +131,7 @@ class DwdWeatherWarningsSensor( """Representation of a DWD-Weather-Warnings sensor.""" _attr_attribution = "Data provided by DWD" + _attr_has_entity_name = True def __init__( self, @@ -141,9 +143,12 @@ class DwdWeatherWarningsSensor( super().__init__(coordinator) self.entity_description = description - self._attr_name = f"{DEFAULT_NAME} {entry.title} {description.name}" self._attr_unique_id = f"{entry.unique_id}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, name=f"{DEFAULT_NAME} {entry.title}" + ) + self.api = coordinator.api @property diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index 60e53f90dbd..dc73055174b 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -15,5 +15,15 @@ "already_configured": "Warncell ID / name is already configured.", "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" } + }, + "entity": { + "sensor": { + "current_warning_level": { + "name": "Current warning level" + }, + "advance_warning_level": { + "name": "Advance warning level" + } + } } } diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index 8438307c698..7cced80c97e 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -34,6 +34,7 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.hass, HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2023.12.0", is_fixable=False, is_persistent=False, issue_domain=DOMAIN, diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 85c672e0f64..43a4a5b106b 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -7,8 +7,8 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index ce3ee2bfbec..2c7f8456a72 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -1,17 +1,16 @@ """Support for gauges from flood monitoring API.""" +import asyncio from datetime import timedelta import logging from aioeafm import get_station -import async_timeout from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -49,7 +48,7 @@ async def async_setup_entry( async def async_update_data(): # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts - async with async_timeout.timeout(30): + async with asyncio.timeout(30): data = await get_station(session, station_key) measures = get_measures(data) @@ -95,6 +94,8 @@ class Measurement(CoordinatorEntity, SensorEntity): "from the real-time data API" ) _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, coordinator, key): """Initialise the gauge with a data instance and station.""" @@ -122,11 +123,6 @@ class Measurement(CoordinatorEntity, SensorEntity): """Return the parameter name for the station.""" return self.coordinator.data["measures"][self.key]["parameterName"] - @property - def name(self): - """Return the name of the gauge.""" - return f"{self.station_name} {self.parameter_name} {self.qualifier}" - @property def device_info(self): """Return the device info.""" @@ -135,7 +131,7 @@ class Measurement(CoordinatorEntity, SensorEntity): identifiers={(DOMAIN, "measure-id", self.station_id)}, manufacturer="https://environment.data.gov.uk/", model=self.parameter_name, - name=self.name, + name=f"{self.station_name} {self.parameter_name} {self.qualifier}", ) @property diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index a64851f6696..28bcbbafcb8 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -21,8 +21,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index e65dc221a9f..4ad0190e01a 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -43,7 +43,6 @@ class EcobeeBinarySensor(BinarySensorEntity): self.data = data self.sensor_name = sensor_name.rstrip() self.index = sensor_index - self._state = None @property def unique_id(self): @@ -93,11 +92,6 @@ class EcobeeBinarySensor(BinarySensorEntity): thermostat = self.data.ecobee.get_thermostat(self.index) return thermostat["runtime"]["connected"] - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state == "true" - async def async_update(self) -> None: """Get the latest state of the sensor.""" await self.data.update() @@ -107,5 +101,5 @@ class EcobeeBinarySensor(BinarySensorEntity): for item in sensor["capability"]: if item["type"] != "occupancy": continue - self._state = item["value"] + self._attr_is_on = item["value"] == "true" break diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 8c0b77b913d..b18f646add7 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -310,6 +310,9 @@ class Thermostat(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_humidity = DEFAULT_MIN_HUMIDITY + _attr_max_humidity = DEFAULT_MAX_HUMIDITY + _attr_fan_modes = [FAN_AUTO, FAN_ON] _attr_name = None _attr_has_entity_name = True @@ -324,20 +327,19 @@ class Thermostat(ClimateEntity): self.vacation = None self._last_active_hvac_mode = HVACMode.HEAT_COOL - self._operation_list = [] + self._attr_hvac_modes = [] if self.settings["heatStages"] or self.settings["hasHeatPump"]: - self._operation_list.append(HVACMode.HEAT) + self._attr_hvac_modes.append(HVACMode.HEAT) if self.settings["coolStages"]: - self._operation_list.append(HVACMode.COOL) - if len(self._operation_list) == 2: - self._operation_list.insert(0, HVACMode.HEAT_COOL) - self._operation_list.append(HVACMode.OFF) + self._attr_hvac_modes.append(HVACMode.COOL) + if len(self._attr_hvac_modes) == 2: + self._attr_hvac_modes.insert(0, HVACMode.HEAT_COOL) + self._attr_hvac_modes.append(HVACMode.OFF) self._preset_modes = { comfort["climateRef"]: comfort["name"] for comfort in self.thermostat["program"]["climates"] } - self._fan_modes = [FAN_AUTO, FAN_ON] self.update_without_throttle = False async def async_update(self) -> None: @@ -432,16 +434,6 @@ class Thermostat(ClimateEntity): return self.thermostat["runtime"]["desiredHumidity"] return None - @property - def min_humidity(self) -> int: - """Return the minimum humidity.""" - return DEFAULT_MIN_HUMIDITY - - @property - def max_humidity(self) -> int: - """Return the maximum humidity.""" - return DEFAULT_MAX_HUMIDITY - @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" @@ -465,11 +457,6 @@ class Thermostat(ClimateEntity): """Return the fan setting.""" return self.thermostat["runtime"]["desiredFanMode"] - @property - def fan_modes(self): - """Return the available fan modes.""" - return self._fan_modes - @property def preset_mode(self): """Return current preset mode.""" @@ -498,11 +485,6 @@ class Thermostat(ClimateEntity): """Return current operation.""" return ECOBEE_HVAC_TO_HASS[self.settings["hvacMode"]] - @property - def hvac_modes(self): - """Return the operation modes list.""" - return self._operation_list - @property def current_humidity(self) -> int | None: """Return the current humidity.""" diff --git a/homeassistant/components/ecobee/entity.py b/homeassistant/components/ecobee/entity.py index 4bb2036bb4b..24fe11d17da 100644 --- a/homeassistant/components/ecobee/entity.py +++ b/homeassistant/components/ecobee/entity.py @@ -4,7 +4,8 @@ from __future__ import annotations import logging from typing import Any -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from . import EcobeeData from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index fb5533adf07..d8ebd3d77d8 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -13,7 +13,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -44,6 +44,10 @@ class EcobeeHumidifier(HumidifierEntity): """A humidifier class for an ecobee thermostat with humidifier attached.""" _attr_supported_features = HumidifierEntityFeature.MODES + _attr_available_modes = [MODE_OFF, MODE_AUTO, MODE_MANUAL] + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_min_humidity = DEFAULT_MIN_HUMIDITY + _attr_max_humidity = DEFAULT_MAX_HUMIDITY _attr_has_entity_name = True _attr_name = None @@ -90,31 +94,11 @@ class EcobeeHumidifier(HumidifierEntity): if self.mode != MODE_OFF: self._last_humidifier_on_mode = self.mode - @property - def available_modes(self): - """Return the list of available modes.""" - return [MODE_OFF, MODE_AUTO, MODE_MANUAL] - - @property - def device_class(self): - """Return the device class type.""" - return HumidifierDeviceClass.HUMIDIFIER - @property def is_on(self): """Return True if the humidifier is on.""" return self.mode != MODE_OFF - @property - def max_humidity(self): - """Return the maximum humidity.""" - return DEFAULT_MAX_HUMIDITY - - @property - def min_humidity(self): - """Return the minimum humidity.""" - return DEFAULT_MIN_HUMIDITY - @property def mode(self): """Return the current mode, e.g., off, auto, manual.""" diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 3996ec6fd35..4d07ec9447e 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index d38bc82c6f2..3e71b05af1d 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -12,7 +12,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -22,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -59,6 +61,7 @@ class EcobeeWeather(WeatherEntity): _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_has_entity_name = True _attr_name = None + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY def __init__(self, data, name, index): """Initialize the Ecobee weather platform.""" @@ -161,13 +164,12 @@ class EcobeeWeather(WeatherEntity): time = self.weather.get("timestamp", "UNKNOWN") return f"Ecobee weather provided by {station} at {time} UTC" - @property - def forecast(self): + def _forecast(self) -> list[Forecast] | None: """Return the forecast array.""" if "forecasts" not in self.weather: return None - forecasts = [] + forecasts: list[Forecast] = [] date = dt_util.utcnow() for day in range(0, 5): forecast = _process_forecast(self.weather["forecasts"][day]) @@ -181,11 +183,21 @@ class EcobeeWeather(WeatherEntity): return forecasts return None + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast array.""" + return self._forecast() + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self._forecast() + async def async_update(self) -> None: """Get the latest weather data.""" await self.data.update() thermostat = self.data.ecobee.get_thermostat(self._index) self.weather = thermostat.get("weather") + await self.async_update_listeners(("daily",)) def _process_forecast(json): diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index afba9ba6837..3005993bf99 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -16,8 +16,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from .const import API_CLIENT, DOMAIN, EQUIPMENT diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index 76bd89af3d5..12fcca449c0 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -5,7 +5,8 @@ import time from aioecowitt import EcoWittSensor -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 5a86d45e9f0..347ee1b242f 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -7,7 +7,6 @@ from __future__ import annotations import logging -# pylint: disable=import-error from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame import voluptuous as vol diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 3ce42198fbd..c2fab739789 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -24,11 +24,11 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow @@ -72,6 +72,15 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:flash", ), # C=1: Active power + + # D=7: Current value + # E=0: Total + SensorEntityDescription( + key="1-0:1.7.0*255", + translation_key="positive_active_instantaneous_power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + # C=1: Active energy + # D=8: Time integral 1 # E=0: Total SensorEntityDescription( @@ -100,7 +109,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="1-0:1.17.0*255", translation_key="last_signed_positive_active_energy_total", ), - # C=2: Active power - + # C=2: Active energy - # D=8: Time integral 1 # E=0: Total SensorEntityDescription( diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json index 43978642943..b23cb8103fa 100644 --- a/homeassistant/components/edl21/strings.json +++ b/homeassistant/components/edl21/strings.json @@ -26,6 +26,9 @@ "firmware_version_number": { "name": "Firmware version number" }, + "positive_active_instantaneous_power": { + "name": "Positive active instantaneous power" + }, "positive_active_energy_total": { "name": "Positive active energy total" }, diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py index ce8483672a2..0ca5bf1d8f7 100644 --- a/homeassistant/components/efergy/__init__.py +++ b/homeassistant/components/efergy/__init__.py @@ -9,7 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 2642505fbea..b8066f2eb31 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -24,8 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import async_get -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo, async_get from homeassistant.helpers.typing import UNDEFINED, ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 59523d5a4cb..086a5288f77 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index 3ae6b1c70cf..5af02f69bcf 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -15,9 +15,7 @@ from . import api from .const import DOMAIN from .coordinator import ElectricKiwiHOPDataCoordinator -PLATFORMS: list[Platform] = [ - Platform.SENSOR, -] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SELECT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 3e0ba997cd4..49611f9febd 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -1,9 +1,9 @@ """Electric Kiwi coordinators.""" +import asyncio from collections import OrderedDict from datetime import timedelta import logging -import async_timeout from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.model import Hop, HopIntervals @@ -61,7 +61,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): filters the intervals to remove ones that are not active """ try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): if self.hop_intervals is None: hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() hop_intervals.intervals = OrderedDict( diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py new file mode 100644 index 00000000000..9d883c72d1e --- /dev/null +++ b/homeassistant/components/electric_kiwi/select.py @@ -0,0 +1,67 @@ +"""Support for Electric Kiwi hour of free power.""" +from __future__ import annotations + +import logging + +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 homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import ElectricKiwiHOPDataCoordinator + +_LOGGER = logging.getLogger(__name__) +ATTR_EK_HOP_SELECT = "hop_select" + +HOP_SELECT = SelectEntityDescription( + entity_category=EntityCategory.CONFIG, + key=ATTR_EK_HOP_SELECT, + translation_key="hopselector", +) + + +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] + + _LOGGER.debug("Setting up select entity") + async_add_entities([ElectricKiwiSelectHOPEntity(hop_coordinator, HOP_SELECT)]) + + +class ElectricKiwiSelectHOPEntity( + CoordinatorEntity[ElectricKiwiHOPDataCoordinator], SelectEntity +): + """Entity object for seeing and setting the hour of free power.""" + + entity_description: SelectEntityDescription + _attr_has_entity_name = True + _attr_attribution = ATTRIBUTION + values_dict: dict[str, int] + + def __init__( + self, + coordinator: ElectricKiwiHOPDataCoordinator, + description: SelectEntityDescription, + ) -> None: + """Initialise the HOP selection entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" + self.entity_description = description + self.values_dict = coordinator.get_hop_options() + self._attr_options = list(self.values_dict) + + @property + def current_option(self) -> str | None: + """Return the currently selected option.""" + return f"{self.coordinator.data.start.start_time} - {self.coordinator.data.end.end_time}" + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + value = self.values_dict[option] + await self.coordinator.async_update_hop(value) + self.async_write_ha_state() diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index a657b768aa5..8c983b92dd5 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -46,16 +46,18 @@ class ElectricKiwiHOPSensorEntityDescription( def _check_and_move_time(hop: Hop, time: str) -> datetime: """Return the time a day forward if HOP end_time is in the past.""" date_time = datetime.combine( - datetime.today(), + dt_util.start_of_local_day(), datetime.strptime(time, "%I:%M %p").time(), - ).astimezone(dt_util.DEFAULT_TIME_ZONE) + dt_util.DEFAULT_TIME_ZONE, + ) end_time = datetime.combine( - datetime.today(), + dt_util.start_of_local_day(), datetime.strptime(hop.end.end_time, "%I:%M %p").time(), - ).astimezone(dt_util.DEFAULT_TIME_ZONE) + dt_util.DEFAULT_TIME_ZONE, + ) - if end_time < datetime.now().astimezone(dt_util.DEFAULT_TIME_ZONE): + if end_time < dt_util.now(): return date_time + timedelta(days=1) return date_time @@ -99,13 +101,13 @@ class ElectricKiwiHOPEntity( def __init__( self, - hop_coordinator: ElectricKiwiHOPDataCoordinator, + coordinator: ElectricKiwiHOPDataCoordinator, description: ElectricKiwiHOPSensorEntityDescription, ) -> None: """Entity object for Electric Kiwi sensor.""" - super().__init__(hop_coordinator) + super().__init__(coordinator) - self._attr_unique_id = f"{self.coordinator._ek_api.customer_number}_{self.coordinator._ek_api.connection_id}_{description.key}" + self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" self.entity_description = description @property diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 19056180f17..81de5cef896 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -31,6 +31,11 @@ "hopfreepowerend": { "name": "Hour of free power end" } + }, + "select": { + "hopselector": { + "name": "Hour of free power" + } } } } diff --git a/homeassistant/components/elgato/entity.py b/homeassistant/components/elgato/entity.py index 041a3196df5..4f4c2a9d8e9 100644 --- a/homeassistant/components/elgato/entity.py +++ b/homeassistant/components/elgato/entity.py @@ -2,8 +2,11 @@ from __future__ import annotations from homeassistant.const import CONF_MAC -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index c20621ce60f..352c8419106 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -8,7 +8,6 @@ import re from types import MappingProxyType from typing import Any, cast -import async_timeout from elkm1_lib.elements import Element from elkm1_lib.elk import Elk from elkm1_lib.util import parse_url @@ -31,8 +30,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -382,7 +381,7 @@ async def async_wait_for_elk_to_sync( ): _LOGGER.debug("Waiting for %s event for %s seconds", name, timeout) try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await event.wait() except asyncio.TimeoutError: _LOGGER.debug("Timed out waiting for %s event", name) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index d0094a5b37b..1ece7a7758a 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -5,7 +5,6 @@ from typing import Any from elkm1_lib.const import ThermostatFan, ThermostatMode, ThermostatSetting from elkm1_lib.elements import Element -from elkm1_lib.elk import Elk from elkm1_lib.thermostats import Thermostat from homeassistant.components.climate import ( @@ -80,13 +79,14 @@ class ElkThermostat(ElkEntity, ClimateEntity): | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + _attr_min_temp = 1 + _attr_max_temp = 99 + _attr_hvac_modes = SUPPORT_HVAC + _attr_hvac_mode: HVACMode | None = None + _attr_target_temperature_step = 1 + _attr_fan_modes = [FAN_AUTO, FAN_ON] _element: Thermostat - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: - """Initialize climate entity.""" - super().__init__(element, elk, elk_data) - self._state: HVACMode | None = None - @property def temperature_unit(self) -> str: """Return the temperature unit.""" @@ -119,41 +119,16 @@ class ElkThermostat(ElkEntity, ClimateEntity): """Return the low target temperature.""" return self._element.heat_setpoint - @property - def target_temperature_step(self) -> float: - """Return the supported step of target temperature.""" - return 1 - @property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._element.humidity - @property - def hvac_mode(self) -> HVACMode | None: - """Return current operation ie. heat, cool, idle.""" - return self._state - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available operation modes.""" - return SUPPORT_HVAC - @property def is_aux_heat(self) -> bool: """Return if aux heater is on.""" return self._element.mode == ThermostatMode.EMERGENCY_HEAT - @property - def min_temp(self) -> float: - """Return the minimum temperature supported.""" - return 1 - - @property - def max_temp(self) -> float: - """Return the maximum temperature supported.""" - return 99 - @property def fan_mode(self) -> str | None: """Return the fan setting.""" @@ -180,11 +155,6 @@ class ElkThermostat(ElkEntity, ClimateEntity): """Turn auxiliary heater off.""" self._elk_set(ThermostatMode.HEAT, None) - @property - def fan_modes(self) -> list[str]: - """Return the list of available fan modes.""" - return [FAN_AUTO, FAN_ON] - async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" thermostat_mode, elk_fan_mode = HASS_TO_ELK_FAN_MODES[fan_mode] @@ -201,8 +171,11 @@ class ElkThermostat(ElkEntity, ClimateEntity): def _element_changed(self, element: Element, changeset: Any) -> None: if self._element.mode is None: - self._state = None + self._attr_hvac_mode = None else: - self._state = ELK_TO_HASS_HVAC_MODES[self._element.mode] - if self._state == HVACMode.OFF and self._element.fan == ThermostatFan.ON: - self._state = HVACMode.FAN_ONLY + self._attr_hvac_mode = ELK_TO_HASS_HVAC_MODES[self._element.mode] + if ( + self._attr_hvac_mode == HVACMode.OFF + and self._element.fan == ThermostatFan.ON + ): + self._attr_hvac_mode = HVACMode.FAN_ONLY diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index fb4326e8917..0de97a1710e 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -6,7 +6,6 @@ from typing import Any from elkm1_lib.const import SettingFormat, ZoneType from elkm1_lib.counters import Counter from elkm1_lib.elements import Element -from elkm1_lib.elk import Elk from elkm1_lib.keypads import Keypad from elkm1_lib.panel import Panel from elkm1_lib.settings import Setting @@ -84,15 +83,7 @@ def temperature_to_state(temperature: int, undefined_temperature: int) -> str | class ElkSensor(ElkAttachedEntity, SensorEntity): """Base representation of Elk-M1 sensor.""" - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: - """Initialize the base of all Elk sensors.""" - super().__init__(element, elk, elk_data) - self._state: str | None = None - - @property - def native_value(self) -> str | None: - """Return the state of the sensor.""" - return self._state + _attr_native_value: str | None = None async def async_counter_refresh(self) -> None: """Refresh the value of a counter from the panel.""" @@ -124,20 +115,17 @@ class ElkSensor(ElkAttachedEntity, SensorEntity): class ElkCounter(ElkSensor): """Representation of an Elk-M1 Counter.""" + _attr_icon = "mdi:numeric" _element: Counter - @property - def icon(self) -> str: - """Icon to use in the frontend.""" - return "mdi:numeric" - def _element_changed(self, _: Element, changeset: Any) -> None: - self._state = self._element.value + self._attr_native_value = self._element.value class ElkKeypad(ElkSensor): """Representation of an Elk-M1 Keypad.""" + _attr_icon = "mdi:thermometer-lines" _element: Keypad @property @@ -150,17 +138,12 @@ class ElkKeypad(ElkSensor): """Return the unit of measurement.""" return self._temperature_unit - @property - def icon(self) -> str: - """Icon to use in the frontend.""" - return "mdi:thermometer-lines" - @property def extra_state_attributes(self) -> dict[str, Any]: """Attributes of the sensor.""" attrs: dict[str, Any] = self.initial_attrs() attrs["area"] = self._element.area + 1 - attrs["temperature"] = self._state + attrs["temperature"] = self._attr_native_value attrs["last_user_time"] = self._element.last_user_time.isoformat() attrs["last_user"] = self._element.last_user + 1 attrs["code"] = self._element.code @@ -169,7 +152,7 @@ class ElkKeypad(ElkSensor): return attrs def _element_changed(self, _: Element, changeset: Any) -> None: - self._state = temperature_to_state( + self._attr_native_value = temperature_to_state( self._element.temperature, UNDEFINED_TEMPERATURE ) @@ -177,14 +160,10 @@ class ElkKeypad(ElkSensor): class ElkPanel(ElkSensor): """Representation of an Elk-M1 Panel.""" + _attr_icon = "mdi:home" _attr_entity_category = EntityCategory.DIAGNOSTIC _element: Panel - @property - def icon(self) -> str: - """Icon to use in the frontend.""" - return "mdi:home" - @property def extra_state_attributes(self) -> dict[str, Any]: """Attributes of the sensor.""" @@ -194,25 +173,21 @@ class ElkPanel(ElkSensor): def _element_changed(self, _: Element, changeset: Any) -> None: if self._elk.is_connected(): - self._state = ( + self._attr_native_value = ( "Paused" if self._element.remote_programming_status else "Connected" ) else: - self._state = "Disconnected" + self._attr_native_value = "Disconnected" class ElkSetting(ElkSensor): """Representation of an Elk-M1 Setting.""" + _attr_icon = "mdi:numeric" _element: Setting - @property - def icon(self) -> str: - """Icon to use in the frontend.""" - return "mdi:numeric" - def _element_changed(self, _: Element, changeset: Any) -> None: - self._state = self._element.value + self._attr_native_value = self._element.value @property def extra_state_attributes(self) -> dict[str, Any]: @@ -282,10 +257,10 @@ class ElkZone(ElkSensor): def _element_changed(self, _: Element, changeset: Any) -> None: if self._element.definition == ZoneType.TEMPERATURE: - self._state = temperature_to_state( + self._attr_native_value = temperature_to_state( self._element.temperature, UNDEFINED_TEMPERATURE ) elif self._element.definition == ZoneType.ANALOG_ZONE: - self._state = f"{self._element.voltage}" + self._attr_native_value = f"{self._element.voltage}" else: - self._state = pretty_const(self._element.logical_status.name) + self._attr_native_value = pretty_const(self._element.logical_status.name) diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index 6eb4cd654c5..0defbe464f9 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -60,12 +60,9 @@ async def async_setup_entry( class ElmaxSensor(ElmaxEntity, BinarySensorEntity): """Elmax Sensor entity implementation.""" + _attr_device_class = BinarySensorDeviceClass.DOOR + @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.coordinator.get_zone_state(self._device.endpoint_id).opened - - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this device, from component DEVICE_CLASSES.""" - return BinarySensorDeviceClass.DOOR diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index b0f51740b04..440344fb839 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -1,11 +1,11 @@ """Elmax integration common classes and utilities.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from logging import Logger -import async_timeout from elmax_api.exceptions import ( ElmaxApiError, ElmaxBadLoginError, @@ -16,12 +16,13 @@ from elmax_api.exceptions import ( from elmax_api.http import Elmax from elmax_api.model.actuator import Actuator from elmax_api.model.area import Area +from elmax_api.model.cover import Cover from elmax_api.model.endpoint import DeviceEndpoint from elmax_api.model.panel import PanelEntry, PanelStatus from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -80,6 +81,12 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): return self._state_by_endpoint[area_id] raise HomeAssistantError("Unknown area") + def get_cover_state(self, cover_id: str) -> Cover: + """Return state of a specific cover.""" + if self._state_by_endpoint is not None: + return self._state_by_endpoint[cover_id] + raise HomeAssistantError("Unknown cover") + @property def http_client(self): """Return the current http client being used by this instance.""" @@ -87,7 +94,7 @@ class ElmaxCoordinator(DataUpdateCoordinator[PanelStatus]): async def _async_update_data(self): try: - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with asyncio.timeout(DEFAULT_TIMEOUT): # Retrieve the panel online status first panels = await self._client.list_control_panels() panel = next( @@ -150,35 +157,16 @@ class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]): super().__init__(coordinator=coordinator) self._panel = panel self._device = elmax_device - self._panel_version = panel_version - self._client = coordinator.http_client - - @property - def panel_id(self) -> str: - """Retrieve the panel id.""" - return self._panel.hash - - @property - def unique_id(self) -> str | None: - """Provide a unique id for this entity.""" - return self._device.endpoint_id - - @property - def name(self) -> str | None: - """Return the entity name.""" - return self._device.name - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( - identifiers={(DOMAIN, self._panel.hash)}, - name=self._panel.get_name_by_user( - self.coordinator.http_client.get_authenticated_username() + self._attr_unique_id = elmax_device.endpoint_id + self._attr_name = elmax_device.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, panel.hash)}, + name=panel.get_name_by_user( + coordinator.http_client.get_authenticated_username() ), manufacturer="Elmax", - model=self._panel_version, - sw_version=self._panel_version, + model=panel_version, + sw_version=panel_version, ) @property diff --git a/homeassistant/components/elmax/const.py b/homeassistant/components/elmax/const.py index cd35211e592..cd2c73002a4 100644 --- a/homeassistant/components/elmax/const.py +++ b/homeassistant/components/elmax/const.py @@ -15,6 +15,7 @@ ELMAX_PLATFORMS = [ Platform.SWITCH, Platform.BINARY_SENSOR, Platform.ALARM_CONTROL_PANEL, + Platform.COVER, ] POLLING_SECONDS = 30 diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py new file mode 100644 index 00000000000..8a6acb154aa --- /dev/null +++ b/homeassistant/components/elmax/cover.py @@ -0,0 +1,136 @@ +"""Elmax cover platform.""" +from __future__ import annotations + +import logging +from typing import Any + +from elmax_api.model.command import CoverCommand +from elmax_api.model.cover_status import CoverStatus + +from homeassistant.components.cover import CoverEntity, CoverEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ElmaxCoordinator +from .common import ElmaxEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_COMMAND_BY_MOTION_STATUS = ( + { # Maps the stop command to use for every cover motion status + CoverStatus.DOWN: CoverCommand.DOWN, + CoverStatus.UP: CoverCommand.UP, + CoverStatus.IDLE: None, + } +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Elmax cover platform.""" + coordinator: ElmaxCoordinator = hass.data[DOMAIN][config_entry.entry_id] + # Add the cover feature only if supported by the current panel. + if coordinator.data is None or not coordinator.data.cover_feature: + return + + known_devices = set() + + def _discover_new_devices(): + if (panel_status := coordinator.data) is None: + return # In case the panel is offline, its status will be None. In that case, simply do nothing + + # Otherwise, add all the entities we found + entities = [] + for cover in panel_status.covers: + # Skip already handled devices + if cover.endpoint_id in known_devices: + continue + entity = ElmaxCover( + panel=coordinator.panel_entry, + elmax_device=cover, + panel_version=panel_status.release, + coordinator=coordinator, + ) + entities.append(entity) + + if entities: + async_add_entities(entities) + known_devices.update([e.unique_id for e in entities]) + + # Register a listener for the discovery of new devices + config_entry.async_on_unload(coordinator.async_add_listener(_discover_new_devices)) + + # Immediately run a discovery, so we don't need to wait for the next update + _discover_new_devices() + + +class ElmaxCover(ElmaxEntity, CoverEntity): + """Elmax Cover entity implementation.""" + + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + def __check_cover_status(self, status_to_check: CoverStatus) -> bool | None: + """Check if the current cover entity is in a specific state.""" + if ( + state := self.coordinator.get_cover_state(self._device.endpoint_id).status + ) is None: + return None + return state == status_to_check + + @property + def is_closed(self) -> bool | None: + """Tells if the cover is closed or not.""" + return self.coordinator.get_cover_state(self._device.endpoint_id).position == 0 + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self.coordinator.get_cover_state(self._device.endpoint_id).position + + @property + def is_opening(self) -> bool | None: + """Tells if the cover is opening or not.""" + return self.__check_cover_status(CoverStatus.UP) + + @property + def is_closing(self) -> bool | None: + """Return if the cover is closing or not.""" + return self.__check_cover_status(CoverStatus.DOWN) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + # To stop the cover, Elmax requires us to re-issue the same command once again. + # To detect the current motion status, we request an immediate refresh to the coordinator + await self.coordinator.async_request_refresh() + motion_status = self.coordinator.get_cover_state( + self._device.endpoint_id + ).status + command = _COMMAND_BY_MOTION_STATUS[motion_status] + if command: + await self.coordinator.http_client.execute_command( + endpoint_id=self._device.endpoint_id, command=command + ) + else: + _LOGGER.debug("Ignoring stop request as the cover is IDLE") + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self.coordinator.http_client.execute_command( + endpoint_id=self._device.endpoint_id, command=CoverCommand.UP + ) + + async def async_close_cover(self, **kwargs): + """Close the cover.""" + await self.coordinator.http_client.execute_command( + endpoint_id=self._device.endpoint_id, command=CoverCommand.DOWN + ) diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index 431e75a0883..877330892e5 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -68,7 +68,7 @@ class ElmaxSwitch(ElmaxEntity, SwitchEntity): return self.coordinator.get_actuator_state(self._device.endpoint_id).opened async def _wait_for_state_change(self) -> bool: - """Refresh data and wait until the state state changes.""" + """Refresh data and wait until the state changes.""" old_state = self.coordinator.get_actuator_state(self._device.endpoint_id).opened # Wait a bit at first to let Elmax cloud assimilate the new state. diff --git a/homeassistant/components/emonitor/__init__.py b/homeassistant/components/emonitor/__init__.py index ea19808cd37..3bc5c7862cb 100644 --- a/homeassistant/components/emonitor/__init__.py +++ b/homeassistant/components/emonitor/__init__.py @@ -31,6 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name=entry.title, update_method=emonitor.async_get_status, update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE), + always_update=False, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 0cf4f0f2346..6e196eebeb0 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.helpers.update_coordinator import ( diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 104e05605cb..379f0bec9d7 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -225,7 +225,7 @@ class Config: @callback def _clear_exposed_cache(self, event: EventType[EventStateChangedData]) -> None: """Clear the cache of exposed states.""" - self.get_exposed_states.cache_clear() # pylint: disable=no-member + self.get_exposed_states.cache_clear() def is_state_exposed(self, state: State) -> bool: """Cache determine if an entity should be exposed on the emulated bridge.""" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index f0a54ba0ea9..566779671e8 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -11,7 +11,6 @@ import time from typing import Any from aiohttp import web -import async_timeout from homeassistant import core from homeassistant.components import ( @@ -898,7 +897,7 @@ async def wait_for_state_change_or_timeout( unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) try: - async with async_timeout.timeout(STATE_CHANGE_WAIT_TIMEOUT): + async with asyncio.timeout(STATE_CHANGE_WAIT_TIMEOUT): await ev.wait() except asyncio.TimeoutError: pass diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index ae92ee2de58..e9760a96aa4 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -377,11 +377,10 @@ class EnergyCostSensor(SensorEntity): if energy_price_unit is None: converted_energy_price = energy_price else: - if self._adapter.source_type == "grid": - converter: Callable[ - [float, str, str], float - ] = unit_conversion.EnergyConverter.convert - elif self._adapter.source_type in ("gas", "water"): + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: converter = unit_conversion.VolumeConverter.convert converted_energy_price = converter( diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index 5e3e402efbf..3b0c05b7368 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -50,6 +50,7 @@ async def async_get_config_entry_diagnostics( "highest_price_time": coordinator.data.energy_today.highest_price_time, "lowest_price_time": coordinator.data.energy_today.lowest_price_time, "percentage_of_max": coordinator.data.energy_today.pct_of_max_price, + "hours_priced_equal_or_lower": coordinator.data.energy_today.hours_priced_equal_or_lower, }, "gas": { "current_hour_price": get_gas_price(coordinator.data, 0), diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 05d23ca4464..8e2b8aba894 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.4.1"] + "requirements": ["energyzero==0.5.0"] } diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 17052dfab57..2468e5e68bf 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -13,10 +13,15 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CURRENCY_EURO, PERCENTAGE, UnitOfEnergy, UnitOfVolume +from homeassistant.const import ( + CURRENCY_EURO, + PERCENTAGE, + UnitOfEnergy, + UnitOfTime, + UnitOfVolume, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -115,6 +120,14 @@ SENSORS: tuple[EnergyZeroSensorEntityDescription, ...] = ( icon="mdi:percent", value_fn=lambda data: data.energy_today.pct_of_max_price, ), + EnergyZeroSensorEntityDescription( + key="hours_priced_equal_or_lower", + translation_key="hours_priced_equal_or_lower", + service_type="today_energy", + native_unit_of_measurement=UnitOfTime.HOURS, + icon="mdi:clock", + value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower, + ), ) diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 93fb264b01d..a27ce236c28 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -37,9 +37,6 @@ }, "hours_priced_equal_or_lower": { "name": "Hours priced equal or lower than current - today" - }, - "hours_priced_equal_or_higher": { - "name": "Hours priced equal or higher than current - today" } } } diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index e7f94647941..25fc8c4f50a 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -60,21 +60,12 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity): device_class: BinarySensorDeviceClass | None, ) -> None: """Initialize the EnOcean binary sensor.""" - super().__init__(dev_id, dev_name) - self._device_class = device_class + super().__init__(dev_id) + self._attr_device_class = device_class self.which = -1 self.onoff = -1 self._attr_unique_id = f"{combine_hex(dev_id)}-{device_class}" - - @property - def name(self): - """Return the default name for the binary sensor.""" - return self.dev_name - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class + self._attr_name = dev_name def value_changed(self, packet): """Fire an event with the data that have changed. diff --git a/homeassistant/components/enocean/device.py b/homeassistant/components/enocean/device.py index 1c98b4dd234..220f940f37f 100644 --- a/homeassistant/components/enocean/device.py +++ b/homeassistant/components/enocean/device.py @@ -11,10 +11,9 @@ from .const import SIGNAL_RECEIVE_MESSAGE, SIGNAL_SEND_MESSAGE class EnOceanEntity(Entity): """Parent class for all entities associated with the EnOcean component.""" - def __init__(self, dev_id: list[int], dev_name: str) -> None: + def __init__(self, dev_id: list[int]) -> None: """Initialize the device.""" self.dev_id = dev_id - self.dev_name = dev_name async def async_added_to_hass(self): """Register callbacks.""" diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index e2a194af8ba..2500ad7ce94 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -53,47 +53,29 @@ class EnOceanLight(EnOceanEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_brightness = 50 + _attr_is_on = False def __init__(self, sender_id: list[int], dev_id: list[int], dev_name: str) -> None: """Initialize the EnOcean light source.""" - super().__init__(dev_id, dev_name) - self._on_state = False - self._brightness = 50 + super().__init__(dev_id) self._sender_id = sender_id - self._attr_unique_id = f"{combine_hex(dev_id)}" - - @property - def name(self): - """Return the name of the device if any.""" - return self.dev_name - - @property - def brightness(self): - """Brightness of the light. - - This method is optional. Removing it indicates to Home Assistant - that brightness is not supported for this light. - """ - return self._brightness - - @property - def is_on(self): - """If light is on.""" - return self._on_state + self._attr_unique_id = str(combine_hex(dev_id)) + self._attr_name = dev_name def turn_on(self, **kwargs: Any) -> None: """Turn the light source on or sets a specific dimmer value.""" if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None: - self._brightness = brightness + self._attr_brightness = brightness - bval = math.floor(self._brightness / 256.0 * 100.0) + bval = math.floor(self._attr_brightness / 256.0 * 100.0) if bval == 0: bval = 1 command = [0xA5, 0x02, bval, 0x01, 0x09] command.extend(self._sender_id) command.extend([0x00]) self.send_command(command, [], 0x01) - self._on_state = True + self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Turn the light source off.""" @@ -101,7 +83,7 @@ class EnOceanLight(EnOceanEntity, LightEntity): command.extend(self._sender_id) command.extend([0x00]) self.send_command(command, [], 0x01) - self._on_state = False + self._attr_is_on = False def value_changed(self, packet): """Update the internal state of this device. @@ -111,6 +93,6 @@ class EnOceanLight(EnOceanEntity, LightEntity): """ if packet.data[0] == 0xA5 and packet.data[1] == 0x02: val = packet.data[2] - self._brightness = math.floor(val / 100.0 * 256.0) - self._on_state = bool(val != 0) + self._attr_brightness = math.floor(val / 100.0 * 256.0) + self._attr_is_on = bool(val != 0) self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 5d1c0027791..f63fd7239d0 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -9,8 +9,8 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + RestoreSensor, SensorDeviceClass, - SensorEntity, SensorEntityDescription, SensorStateClass, ) @@ -27,7 +27,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .device import EnOceanEntity @@ -151,9 +150,8 @@ def setup_platform( add_entities(entities) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): - """Representation of an EnOcean sensor device such as a power meter.""" +class EnOceanSensor(EnOceanEntity, RestoreSensor): + """Representation of an EnOcean sensor device such as a power meter.""" def __init__( self, @@ -162,7 +160,7 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): description: EnOceanSensorEntityDescription, ) -> None: """Initialize the EnOcean sensor device.""" - super().__init__(dev_id, dev_name) + super().__init__(dev_id) self.entity_description = description self._attr_name = f"{description.name} {dev_name}" self._attr_unique_id = description.unique_id(dev_id) @@ -174,14 +172,13 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): if self._attr_native_value is not None: return - if (state := await self.async_get_last_state()) is not None: - self._attr_native_value = state.state + if (sensor_data := await self.async_get_last_sensor_data()) is not None: + self._attr_native_value = sensor_data.native_value def value_changed(self, packet): """Update the internal state of the sensor.""" -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanPowerSensor(EnOceanSensor): """Representation of an EnOcean power sensor. @@ -202,7 +199,6 @@ class EnOceanPowerSensor(EnOceanSensor): self.schedule_update_ha_state() -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanTemperatureSensor(EnOceanSensor): """Representation of an EnOcean temperature sensor device. @@ -252,7 +248,6 @@ class EnOceanTemperatureSensor(EnOceanSensor): self.schedule_update_ha_state() -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanHumiditySensor(EnOceanSensor): """Representation of an EnOcean humidity sensor device. @@ -271,7 +266,6 @@ class EnOceanHumiditySensor(EnOceanSensor): self.schedule_update_ha_state() -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanWindowHandle(EnOceanSensor): """Representation of an EnOcean window handle device. diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index c69821c8372..13920f08e85 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -76,24 +76,15 @@ async def async_setup_platform( class EnOceanSwitch(EnOceanEntity, SwitchEntity): """Representation of an EnOcean switch device.""" + _attr_is_on = False + def __init__(self, dev_id: list[int], dev_name: str, channel: int) -> None: """Initialize the EnOcean switch device.""" - super().__init__(dev_id, dev_name) + super().__init__(dev_id) self._light = None - self._on_state = False - self._on_state2 = False self.channel = channel self._attr_unique_id = generate_unique_id(dev_id, channel) - - @property - def is_on(self): - """Return whether the switch is on or off.""" - return self._on_state - - @property - def name(self): - """Return the device name.""" - return self.dev_name + self._attr_name = dev_name def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" @@ -105,7 +96,7 @@ class EnOceanSwitch(EnOceanEntity, SwitchEntity): optional=optional, packet_type=0x01, ) - self._on_state = True + self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" @@ -117,7 +108,7 @@ class EnOceanSwitch(EnOceanEntity, SwitchEntity): optional=optional, packet_type=0x01, ) - self._on_state = False + self._attr_is_on = False def value_changed(self, packet): """Update the internal state of the switch.""" @@ -129,7 +120,7 @@ class EnOceanSwitch(EnOceanEntity, SwitchEntity): divisor = packet.parsed["DIV"]["raw_value"] watts = raw_val / (10**divisor) if watts > 1: - self._on_state = True + self._attr_is_on = True self.schedule_update_ha_state() elif packet.data[0] == 0xD2: # actuator status telegram @@ -138,5 +129,5 @@ class EnOceanSwitch(EnOceanEntity, SwitchEntity): channel = packet.parsed["IO"]["raw_value"] output = packet.parsed["OV"]["raw_value"] if channel == self.channel: - self._on_state = output > 0 + self._attr_is_on = output > 0 self.schedule_update_ha_state() diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 1a4feb59376..2473c2d9b2f 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -1,91 +1,42 @@ """The Enphase Envoy integration.""" from __future__ import annotations -from datetime import timedelta -import logging - -import async_timeout -from envoy_reader.envoy_reader import EnvoyReader -import httpx +from pyenphase import Envoy from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import COORDINATOR, DOMAIN, NAME, PLATFORMS -from .sensor import SENSORS - -SCAN_INTERVAL = timedelta(seconds=60) - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, PLATFORMS +from .coordinator import EnphaseUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Enphase Envoy from a config entry.""" - config = entry.data - name = config[CONF_NAME] - - envoy_reader = EnvoyReader( - config[CONF_HOST], - config[CONF_USERNAME], - config[CONF_PASSWORD], - inverters=True, - async_client=get_async_client(hass), - ) - - async def async_update_data(): - """Fetch data from API endpoint.""" - async with async_timeout.timeout(30): - try: - await envoy_reader.getData() - except httpx.HTTPStatusError as err: - raise ConfigEntryAuthFailed from err - except httpx.HTTPError as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - data = { - description.key: await getattr(envoy_reader, description.key)() - for description in SENSORS - } - data["inverters_production"] = await envoy_reader.inverters_production() - - _LOGGER.debug("Retrieved data from API: %s", data) - - return data - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"envoy {name}", - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - ) - - try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryAuthFailed: - envoy_reader.get_inverters = False - await coordinator.async_config_entry_first_refresh() + host = entry.data[CONF_HOST] + envoy = Envoy(host, get_async_client(hass, verify_ssl=False)) + coordinator = EnphaseUpdateCoordinator(hass, envoy, entry) + await coordinator.async_config_entry_first_refresh() if not entry.unique_id: - try: - serial = await envoy_reader.get_full_serial_number() - except httpx.HTTPError as ex: - raise ConfigEntryNotReady( - f"Could not obtain serial number from envoy: {ex}" - ) from ex + hass.config_entries.async_update_entry(entry, unique_id=envoy.serial_number) - hass.config_entries.async_update_entry(entry, unique_id=serial) + if entry.unique_id != envoy.serial_number: + # If the serial number of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, " + f"found {envoy.serial_number}" + ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - COORDINATOR: coordinator, - NAME: name, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -105,13 +56,13 @@ async def async_remove_config_entry_device( ) -> bool: """Remove an enphase_envoy config entry from a device.""" dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN} - data: dict = hass.data[DOMAIN][config_entry.entry_id] - coordinator: DataUpdateCoordinator = data[COORDINATOR] - envoy_data: dict = coordinator.data + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data envoy_serial_num = config_entry.unique_id if envoy_serial_num in dev_ids: return False - for inverter in envoy_data.get("inverters_production", []): - if str(inverter) in dev_ids: - return False + if envoy_data and envoy_data.inverters: + for inverter in envoy_data.inverters: + if str(inverter) in dev_ids: + return False return True diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py new file mode 100644 index 00000000000..7060943deb8 --- /dev/null +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -0,0 +1,180 @@ +"""Support for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyenphase import EnvoyEncharge, EnvoyEnpower + +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.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity + + +@dataclass +class EnvoyEnchargeRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyEncharge], bool] + + +@dataclass +class EnvoyEnchargeBinarySensorEntityDescription( + BinarySensorEntityDescription, EnvoyEnchargeRequiredKeysMixin +): + """Describes an Envoy Encharge binary sensor entity.""" + + +ENCHARGE_SENSORS = ( + EnvoyEnchargeBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda encharge: encharge.communicating, + ), + EnvoyEnchargeBinarySensorEntityDescription( + key="dc_switch", + translation_key="dc_switch", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda encharge: not encharge.dc_switch_off, + ), +) + + +@dataclass +class EnvoyEnpowerRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyEnpower], bool] + + +@dataclass +class EnvoyEnpowerBinarySensorEntityDescription( + BinarySensorEntityDescription, EnvoyEnpowerRequiredKeysMixin +): + """Describes an Envoy Enpower binary sensor entity.""" + + +ENPOWER_SENSORS = ( + EnvoyEnpowerBinarySensorEntityDescription( + key="communicating", + translation_key="communicating", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda enpower: enpower.communicating, + ), + EnvoyEnpowerBinarySensorEntityDescription( + key="mains_oper_state", + translation_key="grid_status", + icon="mdi:transmission-tower", + value_fn=lambda enpower: enpower.mains_oper_state == "closed", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up envoy binary sensor platform.""" + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None + entities: list[BinarySensorEntity] = [] + if envoy_data.encharge_inventory: + entities.extend( + EnvoyEnchargeBinarySensorEntity(coordinator, description, encharge) + for description in ENCHARGE_SENSORS + for encharge in envoy_data.encharge_inventory + ) + + if envoy_data.enpower: + entities.extend( + EnvoyEnpowerBinarySensorEntity(coordinator, description) + for description in ENPOWER_SENSORS + ) + + async_add_entities(entities) + + +class EnvoyBaseBinarySensorEntity(EnvoyBaseEntity, BinarySensorEntity): + """Defines a base envoy binary_sensor entity.""" + + +class EnvoyEnchargeBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an Encharge binary_sensor entity.""" + + entity_description: EnvoyEnchargeBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyEnchargeBinarySensorEntityDescription, + serial_number: str, + ) -> None: + """Init the Encharge base entity.""" + super().__init__(coordinator, description) + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_{description.key}" + encharge_inventory = self.data.encharge_inventory + assert encharge_inventory is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + manufacturer="Enphase", + model="Encharge", + name=f"Encharge {serial_number}", + sw_version=str(encharge_inventory[self._serial_number].firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def is_on(self) -> bool: + """Return the state of the Encharge binary_sensor.""" + encharge_inventory = self.data.encharge_inventory + assert encharge_inventory is not None + return self.entity_description.value_fn(encharge_inventory[self._serial_number]) + + +class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): + """Defines an Enpower binary_sensor entity.""" + + entity_description: EnvoyEnpowerBinarySensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyEnpowerBinarySensorEntityDescription, + ) -> None: + """Init the Enpower base entity.""" + super().__init__(coordinator, description) + enpower = self.data.enpower + assert enpower is not None + self._attr_unique_id = f"{enpower.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, enpower.serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {enpower.serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def is_on(self) -> bool: + """Return the state of the Enpower binary_sensor.""" + enpower = self.data.enpower + assert enpower is not None + return self.entity_description.value_fn(enpower) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 3707733b1af..b41d29626e7 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -2,12 +2,11 @@ from __future__ import annotations from collections.abc import Mapping -import contextlib import logging from typing import Any -from envoy_reader.envoy_reader import EnvoyReader -import httpx +from awesomeversion import AwesomeVersion +from pyenphase import AUTH_TOKEN_MIN_VERSION, Envoy, EnvoyError import voluptuous as vol from homeassistant import config_entries @@ -15,11 +14,10 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.httpx_client import get_async_client from homeassistant.util.network import is_ipv4_address -from .const import DOMAIN +from .const import DOMAIN, INVALID_AUTH_ERRORS _LOGGER = logging.getLogger(__name__) @@ -27,25 +25,17 @@ ENVOY = "Envoy" CONF_SERIAL = "serial" +INSTALLER_AUTH_USERNAME = "installer" -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> EnvoyReader: + +async def validate_input( + hass: HomeAssistant, host: str, username: str, password: str +) -> Envoy: """Validate the user input allows us to connect.""" - envoy_reader = EnvoyReader( - data[CONF_HOST], - data[CONF_USERNAME], - data[CONF_PASSWORD], - inverters=False, - async_client=get_async_client(hass), - ) - - try: - await envoy_reader.getData() - except httpx.HTTPStatusError as err: - raise InvalidAuth from err - except (RuntimeError, httpx.HTTPError) as err: - raise CannotConnect from err - - return envoy_reader + envoy = Envoy(host, get_async_client(hass, verify_ssl=False)) + await envoy.setup() + await envoy.authenticate(username=username, password=password) + return envoy class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -57,10 +47,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize an envoy flow.""" self.ip_address = None self.username = None + self.protovers: str | None = None self._reauth_entry = None @callback - def _async_generate_schema(self): + def _async_generate_schema(self) -> vol.Schema: """Generate schema.""" schema = {} @@ -68,15 +59,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): schema[vol.Required(CONF_HOST, default=self.ip_address)] = vol.In( [self.ip_address] ) - else: + elif not self._reauth_entry: schema[vol.Required(CONF_HOST)] = str - schema[vol.Optional(CONF_USERNAME, default=self.username or "envoy")] = str + default_username = "" + if ( + not self.username + and self.protovers + and AwesomeVersion(self.protovers) < AUTH_TOKEN_MIN_VERSION + ): + default_username = INSTALLER_AUTH_USERNAME + + schema[ + vol.Optional(CONF_USERNAME, default=self.username or default_username) + ] = str schema[vol.Optional(CONF_PASSWORD, default="")] = str + return vol.Schema(schema) @callback - def _async_current_hosts(self): + def _async_current_hosts(self) -> set[str]: """Return a set of hosts.""" return { entry.data[CONF_HOST] @@ -91,6 +93,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not is_ipv4_address(discovery_info.host): return self.async_abort(reason="not_ipv4_address") serial = discovery_info.properties["serialnum"] + self.protovers = discovery_info.properties.get("protovers") await self.async_set_unique_id(serial) self.ip_address = discovery_info.host self._abort_if_unique_id_configured({CONF_HOST: self.ip_address}) @@ -116,81 +119,84 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + assert self._reauth_entry is not None + if unique_id := self._reauth_entry.unique_id: + await self.async_set_unique_id(unique_id, raise_on_progress=False) return await self.async_step_user() def _async_envoy_name(self) -> str: """Return the name of the envoy.""" - if self.unique_id: - return f"{ENVOY} {self.unique_id}" - return ENVOY - - async def _async_set_unique_id_from_envoy(self, envoy_reader: EnvoyReader) -> bool: - """Set the unique id by fetching it from the envoy.""" - serial = None - with contextlib.suppress(httpx.HTTPError): - serial = await envoy_reader.get_full_serial_number() - if serial: - await self.async_set_unique_id(serial) - return True - return False + return f"{ENVOY} {self.unique_id}" if self.unique_id else ENVOY async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} + + if self._reauth_entry: + host = self._reauth_entry.data[CONF_HOST] + else: + host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" if user_input is not None: - if ( - not self._reauth_entry - and user_input[CONF_HOST] in self._async_current_hosts() - ): - return self.async_abort(reason="already_configured") + if not self._reauth_entry: + if host in self._async_current_hosts(): + return self.async_abort(reason="already_configured") + try: - envoy_reader = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: + envoy = await validate_input( + self.hass, + host, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + except INVALID_AUTH_ERRORS as e: errors["base"] = "invalid_auth" + description_placeholders = {"reason": str(e)} + except EnvoyError as e: + errors["base"] = "cannot_connect" + description_placeholders = {"reason": str(e)} except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - data = user_input.copy() - data[CONF_NAME] = self._async_envoy_name() + name = self._async_envoy_name() if self._reauth_entry: self.hass.config_entries.async_update_entry( self._reauth_entry, - data=data, + 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 and await self._async_set_unique_id_from_envoy( - envoy_reader - ): - data[CONF_NAME] = self._async_envoy_name() + if not self.unique_id: + await self.async_set_unique_id(envoy.serial_number) + name = self._async_envoy_name() if self.unique_id: - self._abort_if_unique_id_configured({CONF_HOST: data[CONF_HOST]}) + self._abort_if_unique_id_configured({CONF_HOST: host}) - return self.async_create_entry(title=data[CONF_NAME], data=data) + # CONF_NAME is still set for legacy backwards compatibility + return self.async_create_entry( + title=name, data={CONF_HOST: host, CONF_NAME: name} | user_input + ) if self.unique_id: self.context["title_placeholders"] = { CONF_SERIAL: self.unique_id, - CONF_HOST: self.ip_address, + CONF_HOST: host, } + return self.async_show_form( step_id="user", data_schema=self._async_generate_schema(), + description_placeholders=description_placeholders, errors=errors, ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index e7c0b7f2a5e..c5656a65b6f 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -1,10 +1,16 @@ """The enphase_envoy component.""" +from pyenphase import EnvoyAuthenticationError, EnvoyAuthenticationRequired + from homeassistant.const import Platform DOMAIN = "enphase_envoy" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] - -COORDINATOR = "coordinator" -NAME = "name" +INVALID_AUTH_ERRORS = (EnvoyAuthenticationError, EnvoyAuthenticationRequired) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py new file mode 100644 index 00000000000..75f2ef39289 --- /dev/null +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -0,0 +1,157 @@ +"""The enphase_envoy component.""" +from __future__ import annotations + +import contextlib +import datetime +from datetime import timedelta +import logging +from typing import Any + +from pyenphase import Envoy, EnvoyError, EnvoyTokenAuth + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import INVALID_AUTH_ERRORS + +SCAN_INTERVAL = timedelta(seconds=60) + +TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) +STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() + +_LOGGER = logging.getLogger(__name__) + + +class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """DataUpdateCoordinator to gather data from any envoy.""" + + envoy_serial_number: str + + def __init__(self, hass: HomeAssistant, envoy: Envoy, entry: ConfigEntry) -> None: + """Initialize DataUpdateCoordinator for the envoy.""" + self.envoy = envoy + entry_data = entry.data + self.entry = entry + self.username = entry_data[CONF_USERNAME] + self.password = entry_data[CONF_PASSWORD] + self._setup_complete = False + self._cancel_token_refresh: CALLBACK_TYPE | None = None + super().__init__( + hass, + _LOGGER, + name=entry_data[CONF_NAME], + update_interval=SCAN_INTERVAL, + always_update=False, + ) + + @callback + def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None: + """Proactively refresh token if its stale in case cloud services goes down.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + expire_time = self.envoy.auth.expire_timestamp + remain = expire_time - now.timestamp() + fresh = remain > STALE_TOKEN_THRESHOLD + name = self.name + _LOGGER.debug("%s: %s seconds remaining on token fresh=%s", name, remain, fresh) + if not fresh: + self.hass.async_create_background_task( + self._async_try_refresh_token(), "{name} token refresh" + ) + + async def _async_try_refresh_token(self) -> None: + """Try to refresh token.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + _LOGGER.debug("%s: Trying to refresh token", self.name) + try: + await self.envoy.auth.refresh() + except EnvoyError as err: + # If we can't refresh the token, we try again later + # If the token actually ends up expiring, we'll + # re-authenticate with username/password and get a new token + # or log an error if that fails + _LOGGER.debug("%s: Error refreshing token: %s", err, self.name) + return + self._async_update_saved_token() + + @callback + def _async_mark_setup_complete(self) -> None: + """Mark setup as complete and setup token refresh if needed.""" + self._setup_complete = True + if self._cancel_token_refresh: + self._cancel_token_refresh() + self._cancel_token_refresh = None + if not isinstance(self.envoy.auth, EnvoyTokenAuth): + return + self._cancel_token_refresh = async_track_time_interval( + self.hass, + self._async_refresh_token_if_needed, + TOKEN_REFRESH_CHECK_INTERVAL, + cancel_on_shutdown=True, + ) + + async def _async_setup_and_authenticate(self) -> None: + """Set up and authenticate with the envoy.""" + envoy = self.envoy + await envoy.setup() + assert envoy.serial_number is not None + self.envoy_serial_number = envoy.serial_number + if token := self.entry.data.get(CONF_TOKEN): + with contextlib.suppress(*INVALID_AUTH_ERRORS): + # Always set the username and password + # so we can refresh the token if needed + await envoy.authenticate( + username=self.username, password=self.password, token=token + ) + # The token is valid, but we still want + # to refresh it if it's stale right away + self._async_refresh_token_if_needed(dt_util.utcnow()) + return + # token likely expired or firmware changed + # so we fall through to authenticate with + # username/password + await self.envoy.authenticate(username=self.username, password=self.password) + # Password auth succeeded, so we can update the token + # if we are using EnvoyTokenAuth + self._async_update_saved_token() + + def _async_update_saved_token(self) -> None: + """Update saved token in config entry.""" + envoy = self.envoy + if not isinstance(envoy.auth, EnvoyTokenAuth): + return + # update token in config entry so we can + # startup without hitting the Cloud API + # as long as the token is valid + _LOGGER.debug("%s: Updating token in config entry from auth", self.name) + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_TOKEN: envoy.auth.token, + }, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch all device and sensor data from api.""" + envoy = self.envoy + for tries in range(2): + try: + if not self._setup_complete: + await self._async_setup_and_authenticate() + self._async_mark_setup_complete() + return (await envoy.update()).raw + except INVALID_AUTH_ERRORS as err: + if self._setup_complete and tries == 0: + # token likely expired or firmware changed, try to re-authenticate + self._setup_complete = False + continue + raise ConfigEntryAuthFailed from err + except EnvoyError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + raise RuntimeError("Unreachable code in _async_update_data") # pragma: no cover diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index daba57e9488..1d589cfb176 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -5,11 +5,17 @@ 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_UNIQUE_ID, CONF_USERNAME +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_UNIQUE_ID, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import COORDINATOR, DOMAIN +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator CONF_TITLE = "title" @@ -20,6 +26,7 @@ TO_REDACT = { CONF_TITLE, CONF_UNIQUE_ID, CONF_USERNAME, + CONF_TOKEN, } @@ -27,7 +34,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data( { diff --git a/homeassistant/components/enphase_envoy/entity.py b/homeassistant/components/enphase_envoy/entity.py new file mode 100644 index 00000000000..16669bcd098 --- /dev/null +++ b/homeassistant/components/enphase_envoy/entity.py @@ -0,0 +1,34 @@ +"""Support for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from pyenphase import EnvoyData + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import EnphaseUpdateCoordinator + + +class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]): + """Defines a base envoy entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Init the Enphase base entity.""" + self.entity_description = description + serial_number = coordinator.envoy.serial_number + assert serial_number is not None + self.envoy_serial_num = serial_number + super().__init__(coordinator) + + @property + def data(self) -> EnvoyData: + """Return envoy data.""" + data = self.coordinator.envoy.data + assert data is not None + return data diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 28a8d0ba28a..a45f4f01e49 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -1,12 +1,12 @@ { "domain": "enphase_envoy", "name": "Enphase Envoy", - "codeowners": ["@gtdiehl"], + "codeowners": ["@bdraco", "@cgarwood", "@dgomes", "@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", - "loggers": ["envoy_reader"], - "requirements": ["envoy-reader==0.20.1"], + "loggers": ["pyenphase"], + "requirements": ["pyenphase==1.9.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py new file mode 100644 index 00000000000..50d4de18f12 --- /dev/null +++ b/homeassistant/components/enphase_envoy/number.py @@ -0,0 +1,116 @@ +"""Number platform for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyenphase import EnvoyDryContactSettings + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity + + +@dataclass +class EnvoyRelayRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyDryContactSettings], float] + + +@dataclass +class EnvoyRelayNumberEntityDescription( + NumberEntityDescription, EnvoyRelayRequiredKeysMixin +): + """Describes an Envoy Dry Contact Relay number entity.""" + + +RELAY_ENTITIES = ( + EnvoyRelayNumberEntityDescription( + key="soc_low", + translation_key="cutoff_battery_level", + device_class=NumberDeviceClass.BATTERY, + entity_category=EntityCategory.CONFIG, + value_fn=lambda relay: relay.soc_low, + ), + EnvoyRelayNumberEntityDescription( + key="soc_high", + translation_key="restore_battery_level", + device_class=NumberDeviceClass.BATTERY, + entity_category=EntityCategory.CONFIG, + value_fn=lambda relay: relay.soc_high, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Enphase Envoy number platform.""" + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None + entities: list[NumberEntity] = [] + if envoy_data.dry_contact_settings: + entities.extend( + EnvoyRelayNumberEntity(coordinator, entity, relay) + for entity in RELAY_ENTITIES + for relay in envoy_data.dry_contact_settings + ) + async_add_entities(entities) + + +class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): + """Representation of an Enphase Enpower number entity.""" + + entity_description: EnvoyRelayNumberEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyRelayNumberEntityDescription, + relay_id: str, + ) -> None: + """Initialize the Enphase relay number entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + enpower = self.data.enpower + assert enpower is not None + serial_number = enpower.serial_number + self._relay_id = relay_id + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, relay_id)}, + manufacturer="Enphase", + model="Dry contact relay", + name=self.data.dry_contact_settings[relay_id].load_name, + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, serial_number), + ) + + @property + def native_value(self) -> float: + """Return the state of the relay entity.""" + return self.entity_description.value_fn( + self.data.dry_contact_settings[self._relay_id] + ) + + async def async_set_native_value(self, value: float) -> None: + """Update the relay.""" + await self.envoy.update_dry_contact( + {"id": self._relay_id, self.entity_description.key: int(value)} + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py new file mode 100644 index 00000000000..5ae73a315f2 --- /dev/null +++ b/homeassistant/components/enphase_envoy/select.py @@ -0,0 +1,166 @@ +"""Select platform for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from pyenphase import Envoy, EnvoyDryContactSettings +from pyenphase.models.dry_contacts import DryContactAction, DryContactMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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 .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity + + +@dataclass +class EnvoyRelayRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyDryContactSettings], str] + update_fn: Callable[ + [Envoy, EnvoyDryContactSettings, str], Coroutine[Any, Any, dict[str, Any]] + ] + + +@dataclass +class EnvoyRelaySelectEntityDescription( + SelectEntityDescription, EnvoyRelayRequiredKeysMixin +): + """Describes an Envoy Dry Contact Relay select entity.""" + + +RELAY_MODE_MAP = { + DryContactMode.MANUAL: "standard", + DryContactMode.STATE_OF_CHARGE: "battery", +} +REVERSE_RELAY_MODE_MAP = {v: k for k, v in RELAY_MODE_MAP.items()} +RELAY_ACTION_MAP = { + DryContactAction.APPLY: "powered", + DryContactAction.SHED: "not_powered", + DryContactAction.SCHEDULE: "schedule", + DryContactAction.NONE: "none", +} +REVERSE_RELAY_ACTION_MAP = {v: k for k, v in RELAY_ACTION_MAP.items()} +MODE_OPTIONS = list(REVERSE_RELAY_MODE_MAP) +ACTION_OPTIONS = list(REVERSE_RELAY_ACTION_MAP) + +RELAY_ENTITIES = ( + EnvoyRelaySelectEntityDescription( + key="mode", + translation_key="relay_mode", + options=MODE_OPTIONS, + value_fn=lambda relay: RELAY_MODE_MAP[relay.mode], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "mode": REVERSE_RELAY_MODE_MAP[value], + } + ), + ), + EnvoyRelaySelectEntityDescription( + key="grid_action", + translation_key="relay_grid_action", + options=ACTION_OPTIONS, + value_fn=lambda relay: RELAY_ACTION_MAP[relay.grid_action], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "grid_action": REVERSE_RELAY_ACTION_MAP[value], + } + ), + ), + EnvoyRelaySelectEntityDescription( + key="microgrid_action", + translation_key="relay_microgrid_action", + options=ACTION_OPTIONS, + value_fn=lambda relay: RELAY_ACTION_MAP[relay.micro_grid_action], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "micro_grid_action": REVERSE_RELAY_ACTION_MAP[value], + } + ), + ), + EnvoyRelaySelectEntityDescription( + key="generator_action", + translation_key="relay_generator_action", + options=ACTION_OPTIONS, + value_fn=lambda relay: RELAY_ACTION_MAP[relay.generator_action], + update_fn=lambda envoy, relay, value: envoy.update_dry_contact( + { + "id": relay.id, + "generator_action": REVERSE_RELAY_ACTION_MAP[value], + } + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Enphase Envoy select platform.""" + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None + entities: list[SelectEntity] = [] + if envoy_data.dry_contact_settings: + entities.extend( + EnvoyRelaySelectEntity(coordinator, entity, relay) + for entity in RELAY_ENTITIES + for relay in envoy_data.dry_contact_settings + ) + async_add_entities(entities) + + +class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): + """Representation of an Enphase Enpower select entity.""" + + entity_description: EnvoyRelaySelectEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyRelaySelectEntityDescription, + relay_id: str, + ) -> None: + """Initialize the Enphase relay select entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + enpower = self.data.enpower + assert enpower is not None + serial_number = enpower.serial_number + self._relay_id = relay_id + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, relay_id)}, + manufacturer="Enphase", + model="Dry contact relay", + name=self.relay.load_name, + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, serial_number), + ) + + @property + def relay(self) -> EnvoyDryContactSettings: + """Return the relay object.""" + return self.data.dry_contact_settings[self._relay_id] + + @property + def current_option(self) -> str: + """Return the state of the Enpower switch.""" + return self.entity_description.value_fn(self.relay) + + async def async_select_option(self, option: str) -> None: + """Update the relay.""" + await self.entity_description.update_fn(self.envoy, self.relay, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index f42c8d94ea2..33b9e3a64df 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -5,7 +5,16 @@ from collections.abc import Callable from dataclasses import dataclass import datetime import logging -from typing import cast + +from pyenphase import ( + EnvoyEncharge, + EnvoyEnchargeAggregate, + EnvoyEnchargePower, + EnvoyEnpower, + EnvoyInverter, + EnvoySystemConsumption, + EnvoySystemProduction, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -14,18 +23,22 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfEnergy, UnitOfPower -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import UNDEFINED -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, +from homeassistant.const import ( + PERCENTAGE, + UnitOfApparentPower, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import COORDINATOR, DOMAIN, NAME +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity ICON = "mdi:flash" _LOGGER = logging.getLogger(__name__) @@ -35,100 +48,295 @@ LAST_REPORTED_KEY = "last_reported" @dataclass -class EnvoyRequiredKeysMixin: +class EnvoyInverterRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[tuple[float, str]], datetime.datetime | float | None] + value_fn: Callable[[EnvoyInverter], datetime.datetime | float] @dataclass -class EnvoySensorEntityDescription(SensorEntityDescription, EnvoyRequiredKeysMixin): +class EnvoyInverterSensorEntityDescription( + SensorEntityDescription, EnvoyInverterRequiredKeysMixin +): """Describes an Envoy inverter sensor entity.""" -def _inverter_last_report_time( - watt_report_time: tuple[float, str] -) -> datetime.datetime | None: - if (report_time := watt_report_time[1]) is None: - return None - if (last_reported_dt := dt_util.parse_datetime(report_time)) is None: - return None - if last_reported_dt.tzinfo is None: - return last_reported_dt.replace(tzinfo=dt_util.UTC) - return last_reported_dt - - INVERTER_SENSORS = ( - EnvoySensorEntityDescription( + EnvoyInverterSensorEntityDescription( key=INVERTERS_KEY, + name=None, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, - value_fn=lambda watt_report_time: watt_report_time[0], + value_fn=lambda inverter: inverter.last_report_watts, ), - EnvoySensorEntityDescription( + EnvoyInverterSensorEntityDescription( key=LAST_REPORTED_KEY, - name="Last Reported", + translation_key=LAST_REPORTED_KEY, device_class=SensorDeviceClass.TIMESTAMP, entity_registry_enabled_default=False, - value_fn=_inverter_last_report_time, + value_fn=lambda inverter: dt_util.utc_from_timestamp(inverter.last_report_date), ), ) -SENSORS = ( - SensorEntityDescription( + +@dataclass +class EnvoyProductionRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoySystemProduction], int] + + +@dataclass +class EnvoyProductionSensorEntityDescription( + SensorEntityDescription, EnvoyProductionRequiredKeysMixin +): + """Describes an Envoy production sensor entity.""" + + +PRODUCTION_SENSORS = ( + EnvoyProductionSensorEntityDescription( key="production", - name="Current Power Production", + translation_key="current_power_production", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=3, + value_fn=lambda production: production.watts_now, ), - SensorEntityDescription( + EnvoyProductionSensorEntityDescription( key="daily_production", - name="Today's Energy Production", + translation_key="daily_production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + value_fn=lambda production: production.watt_hours_today, ), - SensorEntityDescription( + EnvoyProductionSensorEntityDescription( key="seven_days_production", - name="Last Seven Days Energy Production", + translation_key="seven_days_production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + value_fn=lambda production: production.watt_hours_last_7_days, ), - SensorEntityDescription( + EnvoyProductionSensorEntityDescription( key="lifetime_production", - name="Lifetime Energy Production", + translation_key="lifetime_production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + suggested_display_precision=3, + value_fn=lambda production: production.watt_hours_lifetime, ), - SensorEntityDescription( +) + + +@dataclass +class EnvoyConsumptionRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoySystemConsumption], int] + + +@dataclass +class EnvoyConsumptionSensorEntityDescription( + SensorEntityDescription, EnvoyConsumptionRequiredKeysMixin +): + """Describes an Envoy consumption sensor entity.""" + + +CONSUMPTION_SENSORS = ( + EnvoyConsumptionSensorEntityDescription( key="consumption", - name="Current Power Consumption", + translation_key="current_power_consumption", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=3, + value_fn=lambda consumption: consumption.watts_now, ), - SensorEntityDescription( + EnvoyConsumptionSensorEntityDescription( key="daily_consumption", - name="Today's Energy Consumption", + translation_key="daily_consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + value_fn=lambda consumption: consumption.watt_hours_today, ), - SensorEntityDescription( + EnvoyConsumptionSensorEntityDescription( key="seven_days_consumption", - name="Last Seven Days Energy Consumption", + translation_key="seven_days_consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=1, + value_fn=lambda consumption: consumption.watt_hours_last_7_days, ), - SensorEntityDescription( + EnvoyConsumptionSensorEntityDescription( key="lifetime_consumption", - name="Lifetime Energy Consumption", + translation_key="lifetime_consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + suggested_display_precision=3, + value_fn=lambda consumption: consumption.watt_hours_lifetime, + ), +) + + +@dataclass +class EnvoyEnchargeRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyEncharge], datetime.datetime | int | float] + + +@dataclass +class EnvoyEnchargeSensorEntityDescription( + SensorEntityDescription, EnvoyEnchargeRequiredKeysMixin +): + """Describes an Envoy Encharge sensor entity.""" + + +@dataclass +class EnvoyEnchargePowerRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyEnchargePower], int | float] + + +@dataclass +class EnvoyEnchargePowerSensorEntityDescription( + SensorEntityDescription, EnvoyEnchargePowerRequiredKeysMixin +): + """Describes an Envoy Encharge sensor entity.""" + + +ENCHARGE_INVENTORY_SENSORS = ( + EnvoyEnchargeSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda encharge: encharge.temperature, + ), + EnvoyEnchargeSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda encharge: dt_util.utc_from_timestamp(encharge.last_report_date), + ), +) +ENCHARGE_POWER_SENSORS = ( + EnvoyEnchargePowerSensorEntityDescription( + key="soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda encharge: encharge.soc, + ), + EnvoyEnchargePowerSensorEntityDescription( + key="apparent_power_mva", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + value_fn=lambda encharge: encharge.apparent_power_mva * 0.001, + ), + EnvoyEnchargePowerSensorEntityDescription( + key="real_power_mw", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda encharge: encharge.real_power_mw * 0.001, + ), +) + + +@dataclass +class EnvoyEnpowerRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyEnpower], datetime.datetime | int | float] + + +@dataclass +class EnvoyEnpowerSensorEntityDescription( + SensorEntityDescription, EnvoyEnpowerRequiredKeysMixin +): + """Describes an Envoy Encharge sensor entity.""" + + +ENPOWER_SENSORS = ( + EnvoyEnpowerSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda enpower: enpower.temperature, + ), + EnvoyEnpowerSensorEntityDescription( + key=LAST_REPORTED_KEY, + translation_key=LAST_REPORTED_KEY, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda enpower: dt_util.utc_from_timestamp(enpower.last_report_date), + ), +) + + +@dataclass +class EnvoyEnchargeAggregateRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyEnchargeAggregate], int] + + +@dataclass +class EnvoyEnchargeAggregateSensorEntityDescription( + SensorEntityDescription, EnvoyEnchargeAggregateRequiredKeysMixin +): + """Describes an Envoy Encharge sensor entity.""" + + +ENCHARGE_AGGREGATE_SENSORS = ( + EnvoyEnchargeAggregateSensorEntityDescription( + key="battery_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda encharge: encharge.state_of_charge, + ), + EnvoyEnchargeAggregateSensorEntityDescription( + key="reserve_soc", + translation_key="reserve_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda encharge: encharge.reserve_state_of_charge, + ), + EnvoyEnchargeAggregateSensorEntityDescription( + key="available_energy", + translation_key="available_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda encharge: encharge.available_energy, + ), + EnvoyEnchargeAggregateSensorEntityDescription( + key="reserve_energy", + translation_key="reserve_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda encharge: encharge.backup_reserve, + ), + EnvoyEnchargeAggregateSensorEntityDescription( + key="max_capacity", + translation_key="max_capacity", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda encharge: encharge.max_available_capacity, ), ) @@ -139,116 +347,237 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up envoy sensor platform.""" - data: dict = hass.data[DOMAIN][config_entry.entry_id] - coordinator: DataUpdateCoordinator = data[COORDINATOR] - envoy_data: dict = coordinator.data - envoy_name: str = data[NAME] - envoy_serial_num = config_entry.unique_id - assert envoy_serial_num is not None + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None _LOGGER.debug("Envoy data: %s", envoy_data) - entities: list[Envoy | EnvoyInverter] = [] - for description in SENSORS: - sensor_data = envoy_data.get(description.key) - if isinstance(sensor_data, str) and "not available" in sensor_data: - continue - entities.append( - Envoy( - coordinator, - description, - envoy_name, - envoy_serial_num, - ) + entities: list[Entity] = [ + EnvoyProductionEntity(coordinator, description) + for description in PRODUCTION_SENSORS + ] + if envoy_data.system_consumption: + entities.extend( + EnvoyConsumptionEntity(coordinator, description) + for description in CONSUMPTION_SENSORS + ) + if envoy_data.inverters: + entities.extend( + EnvoyInverterEntity(coordinator, description, inverter) + for description in INVERTER_SENSORS + for inverter in envoy_data.inverters ) - if production := envoy_data.get("inverters_production"): + if envoy_data.encharge_inventory: entities.extend( - EnvoyInverter( - coordinator, - description, - envoy_name, - envoy_serial_num, - str(inverter), - ) - for description in INVERTER_SENSORS - for inverter in production + EnvoyEnchargeInventoryEntity(coordinator, description, encharge) + for description in ENCHARGE_INVENTORY_SENSORS + for encharge in envoy_data.encharge_inventory + ) + if envoy_data.encharge_power: + entities.extend( + EnvoyEnchargePowerEntity(coordinator, description, encharge) + for description in ENCHARGE_POWER_SENSORS + for encharge in envoy_data.encharge_power + ) + if envoy_data.encharge_aggregate: + entities.extend( + EnvoyEnchargeAggregateEntity(coordinator, description) + for description in ENCHARGE_AGGREGATE_SENSORS + ) + if envoy_data.enpower: + entities.extend( + EnvoyEnpowerEntity(coordinator, description) + for description in ENPOWER_SENSORS ) async_add_entities(entities) -class Envoy(CoordinatorEntity, SensorEntity): - """Envoy inverter entity.""" +class EnvoySensorBaseEntity(EnvoyBaseEntity, SensorEntity): + """Defines a base envoy entity.""" + + +class EnvoySystemSensorEntity(EnvoySensorBaseEntity): + """Envoy system base entity.""" _attr_icon = ICON def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: EnphaseUpdateCoordinator, description: SensorEntityDescription, - envoy_name: str, - envoy_serial_num: str, ) -> None: """Initialize Envoy entity.""" - self.entity_description = description - self._attr_name = f"{envoy_name} {description.name}" - self._attr_unique_id = f"{envoy_serial_num}_{description.key}" + super().__init__(coordinator, description) + self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, envoy_serial_num)}, + identifiers={(DOMAIN, self.envoy_serial_num)}, manufacturer="Enphase", - model="Envoy", - name=envoy_name, + model=coordinator.envoy.part_number or "Envoy", + name=coordinator.name, + sw_version=str(coordinator.envoy.firmware), ) - super().__init__(coordinator) + + +class EnvoyProductionEntity(EnvoySystemSensorEntity): + """Envoy production entity.""" + + entity_description: EnvoyProductionSensorEntityDescription @property - def native_value(self) -> float | None: + def native_value(self) -> int | None: """Return the state of the sensor.""" - if (value := self.coordinator.data.get(self.entity_description.key)) is None: - return None - return cast(float, value) + system_production = self.data.system_production + assert system_production is not None + return self.entity_description.value_fn(system_production) -class EnvoyInverter(CoordinatorEntity, SensorEntity): +class EnvoyConsumptionEntity(EnvoySystemSensorEntity): + """Envoy consumption entity.""" + + entity_description: EnvoyConsumptionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + system_consumption = self.data.system_consumption + assert system_consumption is not None + return self.entity_description.value_fn(system_consumption) + + +class EnvoyInverterEntity(EnvoySensorBaseEntity): """Envoy inverter entity.""" _attr_icon = ICON - entity_description: EnvoySensorEntityDescription + entity_description: EnvoyInverterSensorEntityDescription def __init__( self, - coordinator: DataUpdateCoordinator, - description: EnvoySensorEntityDescription, - envoy_name: str, - envoy_serial_num: str, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyInverterSensorEntityDescription, serial_number: str, ) -> None: """Initialize Envoy inverter entity.""" - self.entity_description = description + super().__init__(coordinator, description) self._serial_number = serial_number - if description.name is not UNDEFINED: - self._attr_name = ( - f"{envoy_name} Inverter {serial_number} {description.name}" - ) - else: - self._attr_name = f"{envoy_name} Inverter {serial_number}" - if description.key == INVERTERS_KEY: + key = description.key + if key == INVERTERS_KEY: + # Originally there was only one inverter sensor, so we don't want to + # break existing installations by changing the unique_id. self._attr_unique_id = serial_number else: - self._attr_unique_id = f"{serial_number}_{description.key}" + # Additional sensors have a unique_id that includes the + # sensor key. + self._attr_unique_id = f"{serial_number}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial_number)}, name=f"Inverter {serial_number}", manufacturer="Enphase", model="Inverter", - via_device=(DOMAIN, envoy_serial_num), + via_device=(DOMAIN, self.envoy_serial_num), ) - super().__init__(coordinator) @property - def native_value(self) -> datetime.datetime | float | None: + def native_value(self) -> datetime.datetime | float: """Return the state of the sensor.""" - watt_report_time: tuple[float, str] = self.coordinator.data[ - "inverters_production" - ][self._serial_number] - return self.entity_description.value_fn(watt_report_time) + inverters = self.data.inverters + assert inverters is not None + return self.entity_description.value_fn(inverters[self._serial_number]) + + +class EnvoyEnchargeEntity(EnvoySensorBaseEntity): + """Envoy Encharge sensor entity.""" + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyEnchargeSensorEntityDescription + | EnvoyEnchargePowerSensorEntityDescription, + serial_number: str, + ) -> None: + """Initialize Encharge entity.""" + super().__init__(coordinator, description) + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_{description.key}" + encharge_inventory = self.data.encharge_inventory + assert encharge_inventory is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + manufacturer="Enphase", + model="Encharge", + name=f"Encharge {serial_number}", + sw_version=str(encharge_inventory[self._serial_number].firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + +class EnvoyEnchargeInventoryEntity(EnvoyEnchargeEntity): + """Envoy Encharge inventory entity.""" + + entity_description: EnvoyEnchargeSensorEntityDescription + + @property + def native_value(self) -> int | float | datetime.datetime | None: + """Return the state of the inventory sensors.""" + encharge_inventory = self.data.encharge_inventory + assert encharge_inventory is not None + return self.entity_description.value_fn(encharge_inventory[self._serial_number]) + + +class EnvoyEnchargePowerEntity(EnvoyEnchargeEntity): + """Envoy Encharge power entity.""" + + entity_description: EnvoyEnchargePowerSensorEntityDescription + + @property + def native_value(self) -> int | float | None: + """Return the state of the power sensors.""" + encharge_power = self.data.encharge_power + assert encharge_power is not None + return self.entity_description.value_fn(encharge_power[self._serial_number]) + + +class EnvoyEnchargeAggregateEntity(EnvoySystemSensorEntity): + """Envoy Encharge Aggregate sensor entity.""" + + entity_description: EnvoyEnchargeAggregateSensorEntityDescription + + @property + def native_value(self) -> int: + """Return the state of the aggregate sensors.""" + encharge_aggregate = self.data.encharge_aggregate + assert encharge_aggregate is not None + return self.entity_description.value_fn(encharge_aggregate) + + +class EnvoyEnpowerEntity(EnvoySensorBaseEntity): + """Envoy Enpower sensor entity.""" + + entity_description: EnvoyEnpowerSensorEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyEnpowerSensorEntityDescription, + ) -> None: + """Initialize Enpower entity.""" + super().__init__(coordinator, description) + enpower_data = self.data.enpower + assert enpower_data is not None + self._attr_unique_id = f"{enpower_data.serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, enpower_data.serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {enpower_data.serial_number}", + sw_version=str(enpower_data.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def native_value(self) -> datetime.datetime | int | float | None: + """Return the state of the power sensors.""" + enpower = self.data.enpower + assert enpower is not None + return self.entity_description.value_fn(enpower) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 822ee14fc9e..ae0ac31413c 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -3,7 +3,7 @@ "flow_title": "{serial} ({host})", "step": { "user": { - "description": "For newer models, enter username `envoy` without a password. For older models, enter username `installer` without a password. For all other models, enter a valid username and password.", + "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models models, enter username `installer` without a password.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", @@ -12,13 +12,119 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "Cannot connect: {reason}", + "invalid_auth": "Invalid authentication: {reason}", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "communicating": { + "name": "Communicating" + }, + "dc_switch": { + "name": "DC switch" + }, + "grid_status": { + "name": "Grid status" + } + }, + "number": { + "cutoff_battery_level": { + "name": "Cutoff battery level" + }, + "restore_battery_level": { + "name": "Restore battery level" + } + }, + "select": { + "relay_mode": { + "name": "Mode", + "state": { + "standard": "Standard", + "battery": "Battery level" + } + }, + "relay_grid_action": { + "name": "Grid action", + "state": { + "powered": "Powered", + "not_powered": "Not powered", + "schedule": "Follow schedule", + "none": "None" + } + }, + "relay_microgrid_action": { + "name": "Microgrid action", + "state": { + "powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::powered%]", + "not_powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::not_powered%]", + "schedule": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::schedule%]", + "none": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::none%]" + } + }, + "relay_generator_action": { + "name": "Generator action", + "state": { + "powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::powered%]", + "not_powered": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::not_powered%]", + "schedule": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::schedule%]", + "none": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::none%]" + } + } + }, + "sensor": { + "last_reported": { + "name": "Last reported" + }, + "current_power_production": { + "name": "Current power production" + }, + "daily_production": { + "name": "Energy production today" + }, + "seven_days_production": { + "name": "Energy production last seven days" + }, + "lifetime_production": { + "name": "Lifetime energy production" + }, + "current_power_consumption": { + "name": "Current power consumption" + }, + "daily_consumption": { + "name": "Energy consumption today" + }, + "seven_days_consumption": { + "name": "Energy consumption last seven days" + }, + "lifetime_consumption": { + "name": "Lifetime energy consumption" + }, + "reserve_soc": { + "name": "Reserve battery level" + }, + "available_energy": { + "name": "Available battery energy" + }, + "reserve_energy": { + "name": "Reserve battery energy" + }, + "max_capacity": { + "name": "Battery capacity" + }, + "configured_reserve_soc": { + "name": "Configured reserve battery level" + } + }, + "switch": { + "grid_enabled": { + "name": "Grid enabled" + } + } } } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py new file mode 100644 index 00000000000..fb9e14406ac --- /dev/null +++ b/homeassistant/components/enphase_envoy/switch.py @@ -0,0 +1,190 @@ +"""Switch platform for Enphase Envoy solar energy monitor.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from pyenphase import Envoy, EnvoyDryContactStatus, EnvoyEnpower +from pyenphase.models.dry_contacts import DryContactStatus + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EnphaseUpdateCoordinator +from .entity import EnvoyBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EnvoyEnpowerRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyEnpower], bool] + turn_on_fn: Callable[[Envoy], Coroutine[Any, Any, dict[str, Any]]] + turn_off_fn: Callable[[Envoy], Coroutine[Any, Any, dict[str, Any]]] + + +@dataclass +class EnvoyEnpowerSwitchEntityDescription( + SwitchEntityDescription, EnvoyEnpowerRequiredKeysMixin +): + """Describes an Envoy Enpower switch entity.""" + + +@dataclass +class EnvoyDryContactRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyDryContactStatus], bool] + turn_on_fn: Callable[[Envoy, str], Coroutine[Any, Any, dict[str, Any]]] + turn_off_fn: Callable[[Envoy, str], Coroutine[Any, Any, dict[str, Any]]] + + +@dataclass +class EnvoyDryContactSwitchEntityDescription( + SwitchEntityDescription, EnvoyDryContactRequiredKeysMixin +): + """Describes an Envoy Enpower dry contact switch entity.""" + + +ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription( + key="mains_admin_state", + translation_key="grid_enabled", + value_fn=lambda enpower: enpower.mains_admin_state == "closed", + turn_on_fn=lambda envoy: envoy.go_on_grid(), + turn_off_fn=lambda envoy: envoy.go_off_grid(), +) + +RELAY_STATE_SWITCH = EnvoyDryContactSwitchEntityDescription( + key="relay_status", + value_fn=lambda dry_contact: dry_contact.status == DryContactStatus.CLOSED, + turn_on_fn=lambda envoy, id: envoy.close_dry_contact(id), + turn_off_fn=lambda envoy, id: envoy.open_dry_contact(id), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Enphase Envoy switch platform.""" + coordinator: EnphaseUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + envoy_data = coordinator.envoy.data + assert envoy_data is not None + entities: list[SwitchEntity] = [] + if envoy_data.enpower: + entities.extend( + [ + EnvoyEnpowerSwitchEntity( + coordinator, ENPOWER_GRID_SWITCH, envoy_data.enpower + ) + ] + ) + + if envoy_data.dry_contact_status: + entities.extend( + EnvoyDryContactSwitchEntity(coordinator, RELAY_STATE_SWITCH, relay) + for relay in envoy_data.dry_contact_status + ) + + async_add_entities(entities) + + +class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): + """Representation of an Enphase Enpower switch entity.""" + + entity_description: EnvoyEnpowerSwitchEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyEnpowerSwitchEntityDescription, + enpower: EnvoyEnpower, + ) -> None: + """Initialize the Enphase Enpower switch entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + self.enpower = enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def is_on(self) -> bool: + """Return the state of the Enpower switch.""" + enpower = self.data.enpower + assert enpower is not None + return self.entity_description.value_fn(enpower) + + async def async_turn_on(self): + """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): + """Turn off the Enpower switch.""" + await self.entity_description.turn_off_fn(self.envoy) + await self.coordinator.async_request_refresh() + + +class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): + """Representation of an Enphase dry contact switch entity.""" + + entity_description: EnvoyDryContactSwitchEntityDescription + _attr_name = None + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyDryContactSwitchEntityDescription, + relay_id: str, + ) -> None: + """Initialize the Enphase dry contact switch entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + enpower = self.data.enpower + assert enpower is not None + self.relay_id = relay_id + serial_number = enpower.serial_number + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" + relay = self.data.dry_contact_settings[relay_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, relay_id)}, + manufacturer="Enphase", + model="Dry contact relay", + name=relay.load_name, + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, enpower.serial_number), + ) + + @property + def is_on(self) -> bool: + """Return the state of the dry contact.""" + relay = self.data.dry_contact_status[self.relay_id] + assert relay is not None + return self.entity_description.value_fn(relay) + + async def async_turn_on(self): + """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): + """Turn off (open) the dry contact.""" + if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): + self.async_write_ha_state() diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index a8548429d50..64a4b7dad20 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -9,8 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LANGUAGE, CONF_STATION, DOMAIN diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index a9f79907b54..b4b5d27f45f 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -21,7 +21,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, - WeatherEntity, + DOMAIN as WEATHER_DOMAIN, + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,9 +33,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from . import device_info @@ -63,10 +66,27 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] - async_add_entities([ECWeather(coordinator, False), ECWeather(coordinator, True)]) + entity_registry = er.async_get(hass) + + entities = [ECWeather(coordinator, False)] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.unique_id, True), + ): + entities.append(ECWeather(coordinator, True)) + + async_add_entities(entities) -class ECWeather(CoordinatorEntity, WeatherEntity): +def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: + """Calculate unique ID.""" + return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" + + +class ECWeather(SingleCoordinatorWeatherEntity): """Representation of a weather condition.""" _attr_has_entity_name = True @@ -74,6 +94,9 @@ class ECWeather(CoordinatorEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__(self, coordinator, hourly): """Initialize Environment Canada weather.""" @@ -81,8 +104,8 @@ class ECWeather(CoordinatorEntity, WeatherEntity): self.ec_data = coordinator.ec_data self._attr_attribution = self.ec_data.metadata["attribution"] self._attr_translation_key = "hourly_forecast" if hourly else "forecast" - self._attr_unique_id = ( - f"{coordinator.config_entry.unique_id}{'-hourly' if hourly else '-daily'}" + self._attr_unique_id = _calculate_unique_id( + coordinator.config_entry.unique_id, hourly ) self._attr_entity_registry_enabled_default = not hourly self._hourly = hourly @@ -155,20 +178,30 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return "" @property - def forecast(self): + def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" return get_forecast(self.ec_data, self._hourly) + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return get_forecast(self.ec_data, False) -def get_forecast(ec_data, hourly): + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return get_forecast(self.ec_data, True) + + +def get_forecast(ec_data, hourly) -> list[Forecast] | None: """Build the forecast array.""" - forecast_array = [] + forecast_array: list[Forecast] = [] if not hourly: if not (half_days := ec_data.daily_forecasts): return None - today = { + today: Forecast = { ATTR_FORECAST_TIME: dt_util.now().isoformat(), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(half_days[0]["icon_code"]) diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index a0d1476aea7..5c49f566bb5 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -37,7 +37,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 1ac4531a376..700bc61293f 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -import eq3bt as eq3 # pylint: disable=import-error +import eq3bt as eq3 import voluptuous as vol from homeassistant.components.climate import ( diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index 0c85705a2a6..71c8a403f8f 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -18,8 +18,8 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/escea/config_flow.py b/homeassistant/components/escea/config_flow.py index 2a6e19343d9..8766c30c04a 100644 --- a/homeassistant/components/escea/config_flow.py +++ b/homeassistant/components/escea/config_flow.py @@ -3,8 +3,6 @@ import asyncio from contextlib import suppress import logging -from async_timeout import timeout - from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -34,7 +32,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: discovery_service = await async_start_discovery_service(hass) with suppress(asyncio.TimeoutError): - async with timeout(TIMEOUT_DISCOVERY): + async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() remove_handler() diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 4a36535cc9b..bc22cc13d6f 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,9 +1,7 @@ """Support for esphome devices.""" from __future__ import annotations -from aioesphomeapi import ( - APIClient, -) +from aioesphomeapi import APIClient from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry @@ -17,10 +15,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_NOISE_PSK, - DOMAIN, -) +from .const import CONF_NOISE_PSK, DOMAIN from .dashboard import async_setup as async_setup_dashboard from .domain_data import DomainData diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 639f47272d9..6f3f903f248 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -31,11 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry from .enum_mapper import EsphomeEnumMapper _ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[ diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 65a237de4f7..4eb29f0c210 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -14,11 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum from .domain_data import DomainData -from .entity import ( - EsphomeAssistEntity, - EsphomeEntity, - platform_async_setup_entry, -) +from .entity import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry async def async_setup_entry( diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 4acd335c1b8..9ef298145d3 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -16,10 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_ca from ..entry_data import RuntimeEntryData from .cache import ESPHomeBluetoothCache -from .client import ( - ESPHomeClient, - ESPHomeClientData, -) +from .client import ESPHomeClient, ESPHomeClientData from .device import ESPHomeBluetoothDevice from .scanner import ESPHomeScanner diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 748035bedac..411a5b989a3 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -7,9 +7,15 @@ import contextlib from dataclasses import dataclass, field from functools import partial import logging +import sys from typing import Any, TypeVar, cast import uuid +if sys.version_info < (3, 12): + from typing_extensions import Buffer +else: + from collections.abc import Buffer + from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, @@ -22,7 +28,6 @@ from aioesphomeapi import ( from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError from aioesphomeapi.core import BluetoothGATTAPIError from async_interrupt import interrupt -import async_timeout from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient, NotifyCallback from bleak.backends.device import BLEDevice @@ -52,9 +57,7 @@ CCCD_INDICATE_BYTES = b"\x02\x00" DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) -_WrapFuncType = TypeVar( # pylint: disable=invalid-name - "_WrapFuncType", bound=Callable[..., Any] -) +_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) def mac_to_int(address: str) -> int: @@ -402,7 +405,7 @@ class ESPHomeClient(BaseBleakClient): self._ble_device.name, self._ble_device.address, ) - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await bluetooth_device.wait_for_ble_connections_free() @property @@ -623,14 +626,14 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def write_gatt_char( self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - data: bytes | bytearray | memoryview, + characteristic: BleakGATTCharacteristic | int | str | uuid.UUID, + data: Buffer, response: bool = False, ) -> None: """Perform a write operation of the specified GATT characteristic. Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): + characteristic (BleakGATTCharacteristic, int, str or UUID): The characteristic to write to, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. @@ -638,16 +641,14 @@ class ESPHomeClient(BaseBleakClient): response (bool): If write-with-response operation should be done. Defaults to `False`. """ - characteristic = self._resolve_characteristic(char_specifier) + characteristic = self._resolve_characteristic(characteristic) await self._client.bluetooth_gatt_write( self._address_as_int, characteristic.handle, bytes(data), response ) @verify_connected @api_error_as_bleak_error - async def write_gatt_descriptor( - self, handle: int, data: bytes | bytearray | memoryview - ) -> None: + async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: """Perform a write operation on the specified GATT descriptor. Args: diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py index 5013a288dcf..a54e7af59a6 100644 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ b/homeassistant/components/esphome/bluetooth/scanner.py @@ -2,7 +2,10 @@ from __future__ import annotations from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement -from bluetooth_data_tools import int_to_bluetooth_address, parse_advertisement_data +from bluetooth_data_tools import ( + int_to_bluetooth_address, + parse_advertisement_data_tuple, +) from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner from homeassistant.core import callback @@ -11,6 +14,8 @@ from homeassistant.core import callback class ESPHomeScanner(BaseHaRemoteScanner): """Scanner for esphome.""" + __slots__ = () + @callback def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: """Call the registered callback.""" @@ -34,15 +39,10 @@ class ESPHomeScanner(BaseHaRemoteScanner): """Call the registered callback.""" now = MONOTONIC_TIME() for adv in advertisements: - parsed = parse_advertisement_data((adv.data,)) self._async_on_advertisement( int_to_bluetooth_address(adv.address), adv.rssi, - parsed.local_name, - parsed.service_uuids, - parsed.service_data, - parsed.manufacturer_data, - None, + *parse_advertisement_data_tuple((adv.data,)), {"address_type": adv.address_type}, now, ) diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index eca8d226c69..a55acf067f0 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -9,10 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import ( - EsphomeEntity, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, platform_async_setup_entry async def async_setup_entry( diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index f3fb8b867d8..98a4c26621d 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -15,10 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import ( - EsphomeEntity, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, platform_async_setup_entry async def async_setup_entry( diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index a9b184cc936..b34714ff89c 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -55,11 +55,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry from .enum_mapper import EsphomeEnumMapper FAN_QUIET = "quiet" diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 5011439c778..898fb55a3ac 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -427,9 +427,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error talking to the dashboard: %s", err) return False except json.JSONDecodeError as err: - _LOGGER.error( - "Error parsing response from dashboard: %s", err, exc_info=True - ) + _LOGGER.exception("Error parsing response from dashboard: %s", err) return False self._noise_psk = noise_psk diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 45ef8a132f9..4dee3958515 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -17,11 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry async def async_setup_entry( diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 4cbb9cbe847..41b0617e630 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -172,6 +172,7 @@ class ESPHomeDashboard(DataUpdateCoordinator[dict[str, ConfiguredDevice]]): _LOGGER, name="ESPHome Dashboard", update_interval=timedelta(minutes=5), + always_update=False, ) self.addon_slug = addon_slug self.url = url diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index c35b4dc9b13..db300ab1b28 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -4,12 +4,7 @@ from __future__ import annotations from collections.abc import Callable import functools import math -from typing import ( # pylint: disable=unused-import - Any, - Generic, - TypeVar, - cast, -) +from typing import Any, Generic, TypeVar, cast from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, @@ -19,17 +14,14 @@ from aioesphomeapi import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - EntityCategory, -) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, -) -from homeassistant.helpers.entity import DeviceInfo, Entity +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_platform import AddEntitiesCallback from .domain_data import DomainData @@ -113,8 +105,8 @@ def esphome_state_property( if not self._has_state: return None val = func(self) - if isinstance(val, float) and math.isnan(val): - # Home Assistant doesn't use NAN values in state machine + if isinstance(val, float) and not math.isfinite(val): + # Home Assistant doesn't use NaN or inf values in state machine # (not JSON serializable) return None return val diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index b7870e9cca0..ad9403e3601 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -312,7 +312,7 @@ class RuntimeEntryData: and subscription_key not in stale_state and state_type is not CameraState and not ( - state_type is SensorState # pylint: disable=unidiomatic-typecheck + state_type is SensorState # noqa: E721 and (platform_info := self.info.get(SensorInfo)) and (entity_info := platform_info.get(state.key)) and (cast(SensorInfo, entity_info)).force_update diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 27a259f4441..a6ca52d6c1a 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -22,11 +22,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry from .enum_mapper import EsphomeEnumMapper ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 1ecc99730bf..e170d8b3948 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -31,11 +31,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} @@ -185,7 +181,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): try_keep_current_mode = False if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: - # pylint: disable-next=invalid-name *rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment] color_bri = max(rgb) # normalize rgb @@ -198,7 +193,6 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): try_keep_current_mode = False if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: - # pylint: disable-next=invalid-name *rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment] color_bri = max(rgb) # normalize rgb diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 00b94cd15ff..6a0d100e679 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -11,11 +11,7 @@ from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry async def async_setup_entry( diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 71dc02acf02..ee0d2371a56 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -23,11 +23,7 @@ import voluptuous as vol from homeassistant.components import tag, zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_MODE, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.const import ATTR_DEVICE_ID, CONF_MODE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import template @@ -323,7 +319,7 @@ class ESPHomeManager: self.voice_assistant_udp_server = None async def _handle_pipeline_start( - self, conversation_id: str, use_vad: bool + self, conversation_id: str, flags: int ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: @@ -343,11 +339,10 @@ class ESPHomeManager: voice_assistant_udp_server.run_pipeline( device_id=self.device_id, conversation_id=conversation_id or None, - use_vad=use_vad, + flags=flags, ), "esphome.voice_assistant_udp_server.run_pipeline", ) - self.entry_data.async_set_assist_pipeline_state(True) return port @@ -359,51 +354,93 @@ class ESPHomeManager: async def on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry + unique_id = entry.unique_id entry_data = self.entry_data reconnect_logic = self.reconnect_logic + assert reconnect_logic is not None, "Reconnect logic must be set" hass = self.hass cli = self.cli + stored_device_name = entry.data.get(CONF_DEVICE_NAME) + unique_id_is_mac_address = unique_id and ":" in unique_id try: device_info = await cli.device_info() + 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 - # Migrate config entry to new unique ID if necessary - # This was changed in 2023.1 - if entry.unique_id != format_mac(device_info.mac_address): - hass.config_entries.async_update_entry( - entry, unique_id=format_mac(device_info.mac_address) + device_mac = format_mac(device_info.mac_address) + mac_address_matches = unique_id == device_mac + # + # Migrate config entry to new unique ID if the current + # unique id is not a mac address. + # + # This was changed in 2023.1 + if not mac_address_matches and not unique_id_is_mac_address: + hass.config_entries.async_update_entry(entry, unique_id=device_mac) + + if not mac_address_matches and unique_id_is_mac_address: + # If the unique id is a mac address + # and does not match we have the wrong device and we need + # to abort the connection. This can happen if the DHCP + # server changes the IP address of the device and we end up + # connecting to the wrong device. + _LOGGER.error( + "Unexpected device found at %s; " + "expected `%s` with mac address `%s`, " + "found `%s` with mac address `%s`", + self.host, + stored_device_name, + unique_id, + device_info.name, + device_mac, + ) + await cli.disconnect() + await reconnect_logic.stop() + # We don't want to reconnect to the wrong device + # so we stop the reconnect logic and disconnect + # the client. When discovery finds the new IP address + # for the device, the config entry will be updated + # and we will connect to the correct device when + # the config entry gets reloaded by the discovery + # flow. + return + + # Make sure we have the correct device name stored + # so we can map the device to ESPHome Dashboard config + # If we got here, we know the mac address matches or we + # did a migration to the mac address so we can update + # the device name. + if stored_device_name != device_info.name: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} + ) + + entry_data.device_info = device_info + assert cli.api_version is not None + entry_data.api_version = cli.api_version + entry_data.available = True + # Reset expected disconnect flag on successful reconnect + # as it will be flipped to False on unexpected disconnect. + # + # We use this to determine if a deep sleep device should + # be marked as unavailable or not. + entry_data.expected_disconnect = True + if device_info.name: + reconnect_logic.name = device_info.name + + if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): + entry_data.disconnect_callbacks.append( + await async_connect_scanner( + hass, entry, cli, entry_data, self.domain_data.bluetooth_cache ) + ) - # Make sure we have the correct device name stored - # so we can map the device to ESPHome Dashboard config - if entry.data.get(CONF_DEVICE_NAME) != device_info.name: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} - ) - - entry_data.device_info = device_info - assert cli.api_version is not None - entry_data.api_version = cli.api_version - entry_data.available = True - # Reset expected disconnect flag on successful reconnect - # as it will be flipped to False on unexpected disconnect. - # - # We use this to determine if a deep sleep device should - # be marked as unavailable or not. - entry_data.expected_disconnect = True - if entry_data.device_info.name: - assert reconnect_logic is not None, "Reconnect logic must be set" - reconnect_logic.name = entry_data.device_info.name - - if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.append( - await async_connect_scanner( - hass, entry, cli, entry_data, self.domain_data.bluetooth_cache - ) - ) - - self.device_id = _async_setup_device_registry(hass, entry, entry_data) - entry_data.async_update_device_state(hass) + self.device_id = _async_setup_device_registry(hass, entry, entry_data) + entry_data.async_update_device_state(hass) + try: entity_infos, services = await cli.list_entities_services() await entry_data.async_update_static_infos(hass, entry, entity_infos) await _setup_services(hass, entry_data, services) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d35cf90c60f..1c8da971168 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,8 +16,8 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async_interrupt==1.1.1", - "aioesphomeapi==15.1.15", - "bluetooth-data-tools==1.6.1", + "aioesphomeapi==16.0.5", + "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 9d008300966..c77625b14dd 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -25,11 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry from .enum_mapper import EsphomeEnumMapper diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 6be1822f90f..bc694ec39cf 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -16,11 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry from .enum_mapper import EsphomeEnumMapper @@ -77,7 +73,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): def native_value(self) -> float | None: """Return the state of the entity.""" state = self._state - if state.missing_state or math.isnan(state.state): + if state.missing_state or not math.isfinite(state.state): return None return state.state diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 2e658389e03..efc77ff53b8 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -25,11 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry from .enum_mapper import EsphomeEnumMapper @@ -100,7 +96,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): def native_value(self) -> datetime | str | None: """Return the state of the entity.""" state = self._state - if math.isnan(state.state) or state.missing_state: + if state.missing_state or not math.isfinite(state.state): return None if self._attr_device_class == SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(state.state) diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 99894b8501e..b2ceaf0fced 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -11,11 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from .entity import ( - EsphomeEntity, - esphome_state_property, - platform_async_setup_entry, -) +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry async def async_setup_entry( diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 2ac69c3a22d..859b28a53b5 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -16,8 +16,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 6b49549d812..c501d756e54 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -2,27 +2,23 @@ from __future__ import annotations import asyncio -from collections import deque -from collections.abc import AsyncIterable, Callable, MutableSequence, Sequence +from collections.abc import AsyncIterable, Callable import logging import socket from typing import cast -from aioesphomeapi import VoiceAssistantEventType -import async_timeout +from aioesphomeapi import VoiceAssistantCommandFlag, VoiceAssistantEventType from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, PipelineNotFound, + PipelineStage, async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.vad import ( - VadSensitivity, - VoiceCommandSegmenter, -) +from homeassistant.components.assist_pipeline.error import WakeWordDetectionError from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -48,6 +44,8 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START, + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END, } ) @@ -73,6 +71,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.hass = hass assert entry_data.device_info is not None + self.entry_data = entry_data self.device_info = entry_data.device_info self.queue: asyncio.Queue[bytes] = asyncio.Queue() @@ -160,7 +159,9 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = None error = False - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: + self.entry_data.async_set_assist_pipeline_state(True) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: @@ -184,121 +185,33 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): ) else: self._tts_done.set() + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: + assert event.data is not None + if not event.data["wake_word_output"]: + event_type = VoiceAssistantEventType.VOICE_ASSISTANT_ERROR + data_to_send = { + "code": "no_wake_word", + "message": "No wake word detected", + } + error = True elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: assert event.data is not None data_to_send = { "code": event.data["code"], "message": event.data["message"], } - self._tts_done.set() error = True self.handle_event(event_type, data_to_send) if error: + self._tts_done.set() self.handle_finished() - async def _wait_for_speech( - self, - segmenter: VoiceCommandSegmenter, - chunk_buffer: MutableSequence[bytes], - ) -> bool: - """Buffer audio chunks until speech is detected. - - Raises asyncio.TimeoutError if no audio data is retrievable from the queue (device stops sending packets / networking issue). - - Returns True if speech was detected - Returns False if the connection was stopped gracefully (b"" put onto the queue). - """ - # Timeout if no audio comes in for a while. - async with async_timeout.timeout(self.audio_timeout): - chunk = await self.queue.get() - - while chunk: - segmenter.process(chunk) - # Buffer the data we have taken from the queue - chunk_buffer.append(chunk) - if segmenter.in_command: - return True - - async with async_timeout.timeout(self.audio_timeout): - chunk = await self.queue.get() - - # If chunk is falsey, `stop()` was called - return False - - async def _segment_audio( - self, - segmenter: VoiceCommandSegmenter, - chunk_buffer: Sequence[bytes], - ) -> AsyncIterable[bytes]: - """Yield audio chunks until voice command has finished. - - Raises asyncio.TimeoutError if no audio data is retrievable from the queue. - """ - # Buffered chunks first - for buffered_chunk in chunk_buffer: - yield buffered_chunk - - # Timeout if no audio comes in for a while. - async with async_timeout.timeout(self.audio_timeout): - chunk = await self.queue.get() - - while chunk: - if not segmenter.process(chunk): - # Voice command is finished - break - - yield chunk - - async with async_timeout.timeout(self.audio_timeout): - chunk = await self.queue.get() - - async def _iterate_packets_with_vad( - self, pipeline_timeout: float, silence_seconds: float - ) -> Callable[[], AsyncIterable[bytes]] | None: - segmenter = VoiceCommandSegmenter(silence_seconds=silence_seconds) - chunk_buffer: deque[bytes] = deque(maxlen=100) - try: - async with async_timeout.timeout(pipeline_timeout): - speech_detected = await self._wait_for_speech(segmenter, chunk_buffer) - if not speech_detected: - _LOGGER.debug( - "Device stopped sending audio before speech was detected" - ) - self.handle_finished() - return None - except asyncio.TimeoutError: - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": "speech-timeout", - "message": "Timed out waiting for speech", - }, - ) - self.handle_finished() - return None - - async def _stream_packets() -> AsyncIterable[bytes]: - try: - async for chunk in self._segment_audio(segmenter, chunk_buffer): - yield chunk - except asyncio.TimeoutError: - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": "speech-timeout", - "message": "No speech detected", - }, - ) - self.handle_finished() - - return _stream_packets - async def run_pipeline( self, device_id: str, conversation_id: str | None, - use_vad: bool = False, + flags: int = 0, pipeline_timeout: float = 30.0, ) -> None: """Run the Voice Assistant pipeline.""" @@ -307,26 +220,13 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" ) - if use_vad: - stt_stream = await self._iterate_packets_with_vad( - pipeline_timeout, - silence_seconds=VadSensitivity.to_seconds( - pipeline_select.get_vad_sensitivity( - self.hass, - DOMAIN, - self.device_info.mac_address, - ) - ), - ) - # Error or timeout occurred and was handled already - if stt_stream is None: - return - else: - stt_stream = self._iterate_packets - _LOGGER.debug("Starting pipeline") + if flags & VoiceAssistantCommandFlag.USE_WAKE_WORD: + start_stage = PipelineStage.WAKE_WORD + else: + start_stage = PipelineStage.STT try: - async with async_timeout.timeout(pipeline_timeout): + async with asyncio.timeout(pipeline_timeout): await async_pipeline_from_audio_stream( self.hass, context=self.context, @@ -339,13 +239,14 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - stt_stream=stt_stream(), + stt_stream=self._iterate_packets(), pipeline_id=pipeline_select.get_chosen_pipeline( self.hass, DOMAIN, self.device_info.mac_address ), conversation_id=conversation_id, device_id=device_id, tts_audio_output=tts_audio_output, + start_stage=start_stage, ) # Block until TTS is done sending @@ -357,11 +258,23 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, { "code": "pipeline not found", - "message": "Selected pipeline timeout", + "message": "Selected pipeline not found", }, ) _LOGGER.warning("Pipeline not found") + except WakeWordDetectionError as e: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": e.code, + "message": e.message, + }, + ) + _LOGGER.warning("No Wake word provider found") except asyncio.TimeoutError: + if self.stopped: + # The pipeline was stopped gracefully + return self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, { @@ -398,7 +311,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.transport.sendto(chunk, self.remote_addr) await asyncio.sleep( - samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.99 + samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.9 ) sample_offset += samples_in_chunk diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py index 625b5cda0ba..5185dcd8818 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -134,7 +134,6 @@ class EufyHomeLight(LightEntity): """Turn the specified light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) colortemp = kwargs.get(ATTR_COLOR_TEMP) - # pylint: disable-next=invalid-name hs = kwargs.get(ATTR_HS_COLOR) if brightness is not None: diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index 741f71b34d2..3278f1c1387 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -7,19 +7,16 @@ from eufylife_ble_client import MODEL_TO_NAME from homeassistant import config_entries from homeassistant.components.bluetooth import async_address_present -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - UnitOfMass, +from homeassistant.components.sensor import ( + RestoreSensor, + SensorDeviceClass, + SensorEntity, ) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfMass from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util.unit_conversion import MassConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import DOMAIN @@ -111,16 +108,13 @@ class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): return UnitOfMass.KILOGRAMS -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): +class EufyLifeWeightSensorEntity(RestoreSensor, EufyLifeSensorEntity): """Representation of an EufyLife weight sensor.""" _attr_translation_key = "weight" _attr_native_unit_of_measurement = UnitOfMass.KILOGRAMS _attr_device_class = SensorDeviceClass.WEIGHT - _weight_kg: float | None = None - def __init__(self, data: EufyLifeData) -> None: """Initialize the weight sensor entity.""" super().__init__(data) @@ -131,11 +125,6 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Determine if the entity is available.""" return True - @property - def native_value(self) -> float | None: - """Return the native value.""" - return self._weight_kg - @property def suggested_unit_of_measurement(self) -> str | None: """Set the suggested unit based on the unit system.""" @@ -149,7 +138,7 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Handle state update.""" state = self._data.client.state if state is not None and state.final_weight_kg is not None: - self._weight_kg = state.final_weight_kg + self._attr_native_value = state.final_weight_kg super()._handle_state_update(args) @@ -158,30 +147,21 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or last_state.state in IGNORED_STATES: + last_sensor_data = await self.async_get_last_sensor_data() + + if not last_state or not last_sensor_data or last_state.state in IGNORED_STATES: return - last_weight = float(last_state.state) - last_weight_unit = last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - # Since the RestoreEntity stores the state using the displayed unit, - # not the native unit, we need to convert the state back to the native - # unit. - self._weight_kg = MassConverter.convert( - last_weight, last_weight_unit, self.native_unit_of_measurement - ) + self._attr_native_value = last_sensor_data.native_value -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): +class EufyLifeHeartRateSensorEntity(RestoreSensor, EufyLifeSensorEntity): """Representation of an EufyLife heart rate sensor.""" _attr_translation_key = "heart_rate" _attr_icon = "mdi:heart-pulse" _attr_native_unit_of_measurement = "bpm" - _heart_rate: int | None = None - def __init__(self, data: EufyLifeData) -> None: """Initialize the heart rate sensor entity.""" super().__init__(data) @@ -192,17 +172,12 @@ class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Determine if the entity is available.""" return True - @property - def native_value(self) -> float | None: - """Return the native value.""" - return self._heart_rate - @callback def _handle_state_update(self, *args: Any) -> None: """Handle state update.""" state = self._data.client.state if state is not None and state.heart_rate is not None: - self._heart_rate = state.heart_rate + self._attr_native_value = state.heart_rate super()._handle_state_update(args) @@ -211,7 +186,9 @@ class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or last_state.state in IGNORED_STATES: + last_sensor_data = await self.async_get_last_sensor_data() + + if not last_state or not last_sensor_data or last_state.state in IGNORED_STATES: return - self._heart_rate = int(last_state.state) + self._attr_native_value = last_sensor_data.native_value diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index 839d546588c..3d65a5516c7 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -1,19 +1,19 @@ """The Evil Genius Labs integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import cast from aiohttp import ContentTypeError -from async_timeout import timeout import pyevilgenius from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -85,18 +85,18 @@ class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): async def _async_update_data(self) -> dict: """Update Evil Genius data.""" if not hasattr(self, "info"): - async with timeout(5): + async with asyncio.timeout(5): self.info = await self.client.get_info() if not hasattr(self, "product"): - async with timeout(5): + async with asyncio.timeout(5): try: self.product = await self.client.get_product() except ContentTypeError: # Older versions of the API don't support this self.product = None - async with timeout(5): + async with asyncio.timeout(5): return cast(dict, await self.client.get_all()) diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py index 53303d738a5..beb16115bd7 100644 --- a/homeassistant/components/evil_genius_labs/config_flow.py +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -6,7 +6,6 @@ import logging from typing import Any import aiohttp -import async_timeout import pyevilgenius import voluptuous as vol @@ -31,7 +30,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await hub.get_all() info = await hub.get_info() except aiohttp.ClientError as err: diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index a915619b1b8..5612d0e8522 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -1,10 +1,9 @@ """Light platform for Evil Genius Light.""" from __future__ import annotations +import asyncio from typing import Any, cast -from async_timeout import timeout - from homeassistant.components import light from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature from homeassistant.config_entries import ConfigEntry @@ -89,27 +88,27 @@ class EvilGeniusLight(EvilGeniusEntity, LightEntity): ) -> None: """Turn light on.""" if (brightness := kwargs.get(light.ATTR_BRIGHTNESS)) is not None: - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value("brightness", brightness) # Setting a color will change the effect to "Solid Color" so skip setting effect if (rgb_color := kwargs.get(light.ATTR_RGB_COLOR)) is not None: - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_rgb_color(*rgb_color) elif (effect := kwargs.get(light.ATTR_EFFECT)) is not None: if effect == HA_NO_EFFECT: effect = FIB_NO_EFFECT - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value( "pattern", self.coordinator.data["pattern"]["options"].index(effect) ) - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value("power", 1) @update_when_done async def async_turn_off(self, **kwargs: Any) -> None: """Turn light off.""" - async with timeout(5): + async with asyncio.timeout(5): await self.coordinator.client.set_path_value("power", 0) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index c007de78130..12754af25e8 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -42,6 +42,7 @@ PLATFORMS_BY_TYPE: dict[str, list] = { Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, Platform.UPDATE, ], diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index 32f9b38888f..4dd16b23480 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -21,14 +21,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DATA_COORDINATOR, - DOMAIN, - MANUFACTURER, -) +from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -70,12 +66,12 @@ async def async_setup_entry( DATA_COORDINATOR ] - device_info: DeviceInfo = { - "identifiers": {(DOMAIN, entry.unique_id)}, # type: ignore[arg-type] - "name": "EZVIZ Alarm", - "model": "EZVIZ Alarm", - "manufacturer": MANUFACTURER, - } + device_info = DeviceInfo( + identifiers={(DOMAIN, entry.unique_id)}, # type: ignore[arg-type] + name="EZVIZ Alarm", + model="EZVIZ Alarm", + manufacturer=MANUFACTURER, + ) async_add_entities( [EzvizAlarm(coordinator, entry.entry_id, device_info, ALARM_TYPE)] diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index 3ed61d8fc3d..81697e2772c 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -54,8 +54,6 @@ async def async_setup_entry( class EzvizBinarySensor(EzvizEntity, BinarySensorEntity): """Representation of a EZVIZ sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: EzvizDataUpdateCoordinator, diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 1c04de956c6..2199f82a476 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -103,7 +103,6 @@ class EzvizButtonEntity(EzvizEntity, ButtonEntity): """Representation of a EZVIZ button entity.""" entity_description: EzvizButtonEntityDescription - _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7f03aef1d97..85b1f316a7b 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -170,6 +170,8 @@ async def async_setup_entry( class EzvizCamera(EzvizEntity, Camera): """An implementation of a EZVIZ security camera.""" + _attr_name = None + def __init__( self, hass: HomeAssistant, @@ -192,7 +194,6 @@ class EzvizCamera(EzvizEntity, Camera): self._ffmpeg_arguments = ffmpeg_arguments self._ffmpeg = get_ffmpeg_manager(hass) self._attr_unique_id = serial - self._attr_name = self.data["name"] if camera_password: self._attr_supported_features = CameraEntityFeature.STREAM @@ -287,6 +288,17 @@ class EzvizCamera(EzvizEntity, Camera): 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: diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index ba8ed336a51..427e52f7dd0 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -1,8 +1,8 @@ """Provides the ezviz DataUpdateCoordinator.""" +import asyncio from datetime import timedelta import logging -from async_timeout import timeout from pyezviz.client import EzvizClient from pyezviz.exceptions import ( EzvizAuthTokenExpired, @@ -37,7 +37,7 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: - async with timeout(self._api_timeout): + async with asyncio.timeout(self._api_timeout): return await self.hass.async_add_executor_job( self.ezviz_client.load_cameras ) diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index ccf273a970b..c8ce3daf074 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import Any -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -14,6 +14,8 @@ from .coordinator import EzvizDataUpdateCoordinator class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): """Generic entity encapsulating common features of EZVIZ device.""" + _attr_has_entity_name = True + def __init__( self, coordinator: EzvizDataUpdateCoordinator, @@ -43,6 +45,8 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): class EzvizBaseEntity(Entity): """Generic entity for EZVIZ individual poll entities.""" + _attr_has_entity_name = True + def __init__( self, coordinator: EzvizDataUpdateCoordinator, diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index 9bc65f12355..aeb8eafe68f 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -4,19 +4,12 @@ from __future__ import annotations import logging from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription -from homeassistant.config_entries import ( - ConfigEntry, -) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, -) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ( - DATA_COORDINATOR, - DOMAIN, -) +from .const import DATA_COORDINATOR, DOMAIN from .coordinator import EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -45,8 +38,6 @@ async def async_setup_entry( class EzvizLastMotion(EzvizEntity, ImageEntity): """Return Last Motion Image from Ezviz Camera.""" - _attr_has_entity_name = True - def __init__( self, hass: HomeAssistant, coordinator: EzvizDataUpdateCoordinator, serial: str ) -> None: diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index 9702959649d..558072658d3 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -44,7 +44,7 @@ async def async_setup_entry( class EzvizLight(EzvizEntity, LightEntity): """Representation of a EZVIZ light.""" - _attr_has_entity_name = True + _attr_translation_key = "light" _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -60,7 +60,6 @@ class EzvizLight(EzvizEntity, LightEntity): == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value ) self._attr_unique_id = f"{serial}_Light" - self._attr_name = "Light" self._attr_is_on = self.data["switches"][DeviceSwitchType.ALARM_LIGHT.value] self._attr_brightness = round( percentage_to_ranged_value( diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 74d496ef6c1..ea7a4812b32 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -47,7 +47,7 @@ class EzvizNumberEntityDescription( NUMBER_TYPE = EzvizNumberEntityDescription( key="detection_sensibility", - name="Detection sensitivity", + translation_key="detection_sensibility", icon="mdi:eye", entity_category=EntityCategory.CONFIG, native_min_value=0, @@ -66,7 +66,7 @@ async def async_setup_entry( ] async_add_entities( - EzvizSensor(coordinator, camera, value, entry.entry_id) + EzvizNumber(coordinator, camera, value, entry.entry_id) for camera in coordinator.data for capibility, value in coordinator.data[camera]["supportExt"].items() if capibility == NUMBER_TYPE.supported_ext @@ -74,11 +74,9 @@ async def async_setup_entry( ) -class EzvizSensor(EzvizBaseEntity, NumberEntity): +class EzvizNumber(EzvizBaseEntity, NumberEntity): """Representation of a EZVIZ number entity.""" - _attr_has_entity_name = True - def __init__( self, coordinator: EzvizDataUpdateCoordinator, @@ -86,7 +84,7 @@ class EzvizSensor(EzvizBaseEntity, NumberEntity): value: str, config_entry_id: str, ) -> None: - """Initialize the sensor.""" + """Initialize the entity.""" super().__init__(coordinator, serial) self.sensitivity_type = 3 if value == "3" else 0 self._attr_native_max_value = 100 if value == "3" else 6 diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index ef1dd785392..369a429dbe6 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -63,8 +63,6 @@ async def async_setup_entry( class EzvizSelect(EzvizEntity, SelectEntity): """Representation of a EZVIZ select entity.""" - _attr_has_entity_name = True - def __init__( self, coordinator: EzvizDataUpdateCoordinator, diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 9b19148bdb7..aecf25c2c78 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -92,8 +92,6 @@ async def async_setup_entry( class EzvizSensor(EzvizEntity, SensorEntity): """Representation of a EZVIZ sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: EzvizDataUpdateCoordinator, serial: str, sensor: str ) -> None: diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py new file mode 100644 index 00000000000..1f08b389236 --- /dev/null +++ b/homeassistant/components/ezviz/siren.py @@ -0,0 +1,133 @@ +"""Support for EZVIZ sirens.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import Any + +from pyezviz import HTTPError, PyEzvizError, SupportExt + +from homeassistant.components.siren import ( + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.event as evt +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizBaseEntity + +PARALLEL_UPDATES = 1 +OFF_DELAY = timedelta(seconds=60) # Camera firmware has hard coded turn off. + +SIREN_ENTITY_TYPE = SirenEntityDescription( + key="siren", + translation_key="siren", +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ sensors based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizSirenEntity(coordinator, camera, SIREN_ENTITY_TYPE) + for camera in coordinator.data + for capability, value in coordinator.data[camera]["supportExt"].items() + if capability == str(SupportExt.SupportActiveDefense.value) + if value != "0" + ) + + +class EzvizSirenEntity(EzvizBaseEntity, SirenEntity, RestoreEntity): + """Representation of a EZVIZ Siren entity.""" + + _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF + _attr_should_poll = False + _attr_assumed_state = True + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + description: SirenEntityDescription, + ) -> None: + """Initialize the Siren.""" + super().__init__(coordinator, serial) + self._attr_unique_id = f"{serial}_{description.key}" + self.entity_description = description + self._attr_is_on = False + self._delay_listener: Callable | None = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + if not (last_state := await self.async_get_last_state()): + return + self._attr_is_on = last_state.state == STATE_ON + + if self._attr_is_on: + evt.async_call_later(self.hass, OFF_DELAY, self.off_delay_listener) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off camera siren.""" + try: + result = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.sound_alarm, self._serial, 1 + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn siren off for {self.name}" + ) from err + + if result: + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = None + + self._attr_is_on = False + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on camera siren.""" + try: + result = self.hass.async_add_executor_job( + self.coordinator.ezviz_client.sound_alarm, self._serial, 2 + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to turn siren on for {self.name}" + ) from err + + if result: + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = None + + self._attr_is_on = True + self._delay_listener = evt.async_call_later( + self.hass, OFF_DELAY, self.off_delay_listener + ) + self.async_write_ha_state() + + @callback + def off_delay_listener(self, now: datetime) -> None: + """Switch device off after a delay. + + Camera firmware has hard coded turn off after 60 seconds. + """ + self._attr_is_on = False + self._delay_listener = None + self.async_write_ha_state() diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index d60c4816d24..11144f8ae71 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -92,6 +92,17 @@ } } } + }, + "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": { @@ -132,6 +143,16 @@ "name": "Encryption" } }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + }, + "number": { + "detection_sensibility": { + "name": "Detection sensitivity" + } + }, "sensor": { "alarm_sound_mod": { "name": "Alarm sound level" @@ -163,6 +184,49 @@ "last_alarm_type_name": { "name": "Last alarm type name" } + }, + "switch": { + "status_light": { + "name": "Status light" + }, + "privacy": { + "name": "Privacy" + }, + "infrared_light": { + "name": "Infrared light" + }, + "sleep": { + "name": "Sleep" + }, + "audio": { + "name": "Audio" + }, + "motion_tracking": { + "name": "Motion tracking" + }, + "all_day_video_recording": { + "name": "All day video recording" + }, + "auto_sleep": { + "name": "Auto sleep" + }, + "flicker_light_on_movement": { + "name": "Flicker light on movement" + }, + "pir_motion_activated_light": { + "name": "PIR motion activated light" + }, + "tamper_alarm": { + "name": "Tamper alarm" + }, + "follow_movement": { + "name": "Follow movement" + } + }, + "siren": { + "siren": { + "name": "[%key:component::siren::title%]" + } } }, "services": { diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 58b28477412..4089b0ae393 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -1,14 +1,20 @@ """Support for EZVIZ Switch sensors.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any -from pyezviz.constants import DeviceSwitchType +from pyezviz.constants import DeviceSwitchType, SupportExt from pyezviz.exceptions import HTTPError, PyEzvizError -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_COORDINATOR, DOMAIN @@ -16,6 +22,96 @@ from .coordinator import EzvizDataUpdateCoordinator from .entity import EzvizEntity +@dataclass +class EzvizSwitchEntityDescriptionMixin: + """Mixin values for EZVIZ Switch entities.""" + + supported_ext: str | None + + +@dataclass +class EzvizSwitchEntityDescription( + SwitchEntityDescription, EzvizSwitchEntityDescriptionMixin +): + """Describe a EZVIZ switch.""" + + +SWITCH_TYPES: dict[int, EzvizSwitchEntityDescription] = { + 3: EzvizSwitchEntityDescription( + key="3", + translation_key="status_light", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=None, + ), + 7: EzvizSwitchEntityDescription( + key="7", + translation_key="privacy", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportPtzPrivacy.value), + ), + 10: EzvizSwitchEntityDescription( + key="10", + translation_key="infrared_light", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportCloseInfraredLight.value), + ), + 21: EzvizSwitchEntityDescription( + key="21", + translation_key="sleep", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportSleep.value), + ), + 22: EzvizSwitchEntityDescription( + key="22", + translation_key="audio", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportAudioOnoff.value), + ), + 25: EzvizSwitchEntityDescription( + key="25", + translation_key="motion_tracking", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportIntelligentTrack.value), + ), + 29: EzvizSwitchEntityDescription( + key="29", + translation_key="all_day_video_recording", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportFulldayRecord.value), + ), + 32: EzvizSwitchEntityDescription( + key="32", + translation_key="auto_sleep", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportAutoSleep.value), + ), + 301: EzvizSwitchEntityDescription( + key="301", + translation_key="flicker_light_on_movement", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportActiveDefense.value), + ), + 305: EzvizSwitchEntityDescription( + key="305", + translation_key="pir_motion_activated_light", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportLightRelate.value), + ), + 306: EzvizSwitchEntityDescription( + key="306", + translation_key="tamper_alarm", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportTamperAlarm.value), + ), + 650: EzvizSwitchEntityDescription( + key="650", + translation_key="follow_movement", + device_class=SwitchDeviceClass.SWITCH, + supported_ext=str(SupportExt.SupportTracking.value), + ), +} + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -24,61 +120,64 @@ async def async_setup_entry( DATA_COORDINATOR ] - supported_switches = {switches.value for switches in DeviceSwitchType} - async_add_entities( - [ - EzvizSwitch(coordinator, camera, switch) - for camera in coordinator.data - for switch in coordinator.data[camera].get("switches") - if switch in supported_switches - ] + EzvizSwitch(coordinator, camera, switch_number) + for camera in coordinator.data + for switch_number in coordinator.data[camera]["switches"] + if switch_number in SWITCH_TYPES + if SWITCH_TYPES[switch_number].supported_ext + in coordinator.data[camera]["supportExt"] + or SWITCH_TYPES[switch_number].supported_ext is None ) class EzvizSwitch(EzvizEntity, SwitchEntity): """Representation of a EZVIZ sensor.""" - _attr_device_class = SwitchDeviceClass.SWITCH - def __init__( - self, coordinator: EzvizDataUpdateCoordinator, serial: str, switch: str + self, coordinator: EzvizDataUpdateCoordinator, serial: str, switch_number: int ) -> None: """Initialize the switch.""" super().__init__(coordinator, serial) - self._name = switch - self._attr_name = f"{self._camera_name} {DeviceSwitchType(switch).name.title()}" + self._switch_number = switch_number self._attr_unique_id = ( - f"{serial}_{self._camera_name}.{DeviceSwitchType(switch).name}" + f"{serial}_{self._camera_name}.{DeviceSwitchType(switch_number).name}" ) - - @property - def is_on(self) -> bool: - """Return the state of the switch.""" - return self.data["switches"][self._name] + self.entity_description = SWITCH_TYPES[switch_number] + self._attr_is_on = self.data["switches"][switch_number] async def async_turn_on(self, **kwargs: Any) -> None: """Change a device switch on the camera.""" try: - update_ok = await self.hass.async_add_executor_job( - self.coordinator.ezviz_client.switch_status, self._serial, self._name, 1 - ) + if await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, + self._serial, + self._switch_number, + 1, + ): + self._attr_is_on = True + self.async_write_ha_state() except (HTTPError, PyEzvizError) as err: - raise PyEzvizError(f"Failed to turn on switch {self._name}") from err - - if update_ok: - await self.coordinator.async_request_refresh() + raise HomeAssistantError(f"Failed to turn on switch {self.name}") from err async def async_turn_off(self, **kwargs: Any) -> None: """Change a device switch on the camera.""" try: - update_ok = await self.hass.async_add_executor_job( - self.coordinator.ezviz_client.switch_status, self._serial, self._name, 0 - ) + if await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, + self._serial, + self._switch_number, + 0, + ): + self._attr_is_on = False + self.async_write_ha_state() except (HTTPError, PyEzvizError) as err: - raise PyEzvizError(f"Failed to turn off switch {self._name}") from err + raise HomeAssistantError(f"Failed to turn off switch {self.name}") from err - if update_ok: - await self.coordinator.async_request_refresh() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self.data["switches"].get(self._switch_number) + super()._handle_coordinator_update() diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 3acc1032514..003397d8dda 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -24,7 +24,6 @@ PARALLEL_UPDATES = 1 UPDATE_ENTITY_TYPES = UpdateEntityDescription( key="version", - name="Firmware update", device_class=UpdateDeviceClass.FIRMWARE, ) @@ -49,7 +48,6 @@ async def async_setup_entry( class EzvizUpdateEntity(EzvizEntity, UpdateEntity): """Representation of a EZVIZ Update entity.""" - _attr_has_entity_name = True _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index 10ddb13c228..b165492d076 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -1,9 +1,9 @@ """The FAA Delays integration.""" +import asyncio from datetime import timedelta import logging from aiohttp import ClientConnectionError -from async_timeout import timeout from faadelays import Airport from homeassistant.config_entries import ConfigEntry @@ -56,7 +56,7 @@ class FAADataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): try: - async with timeout(10): + async with asyncio.timeout(10): await self.data.update() except ClientConnectionError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 6be0e3c219f..eef84996d56 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -1,22 +1,23 @@ """Support for RSS/Atom feeds.""" from __future__ import annotations +from calendar import timegm from datetime import datetime, timedelta from logging import getLogger -from os.path import exists +import os import pickle -from threading import Lock -from time import struct_time -from typing import cast +from time import gmtime, struct_time import feedparser import voluptuous as vol from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util _LOGGER = getLogger(__name__) @@ -25,10 +26,12 @@ CONF_MAX_ENTRIES = "max_entries" DEFAULT_MAX_ENTRIES = 20 DEFAULT_SCAN_INTERVAL = timedelta(hours=1) +DELAY_SAVE = 30 DOMAIN = "feedreader" EVENT_FEEDREADER = "feedreader" +STORAGE_VERSION = 1 CONFIG_SCHEMA = vol.Schema( { @@ -46,17 +49,25 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Feedreader component.""" urls: list[str] = config[DOMAIN][CONF_URLS] + if not urls: + return False + scan_interval: timedelta = config[DOMAIN][CONF_SCAN_INTERVAL] max_entries: int = config[DOMAIN][CONF_MAX_ENTRIES] - data_file = hass.config.path(f"{DOMAIN}.pickle") - storage = StoredData(data_file) + old_data_file = hass.config.path(f"{DOMAIN}.pickle") + storage = StoredData(hass, old_data_file) + await storage.async_setup() feeds = [ - FeedManager(url, scan_interval, max_entries, hass, storage) for url in urls + FeedManager(hass, url, scan_interval, max_entries, storage) for url in urls ] - return len(feeds) > 0 + + for feed in feeds: + feed.async_setup() + + return True class FeedManager: @@ -64,50 +75,47 @@ class FeedManager: def __init__( self, + hass: HomeAssistant, url: str, scan_interval: timedelta, max_entries: int, - hass: HomeAssistant, storage: StoredData, ) -> None: """Initialize the FeedManager object, poll as per scan interval.""" + self._hass = hass self._url = url self._scan_interval = scan_interval self._max_entries = max_entries self._feed: feedparser.FeedParserDict | None = None - self._hass = hass self._firstrun = True self._storage = storage self._last_entry_timestamp: struct_time | None = None - self._last_update_successful = False self._has_published_parsed = False self._has_updated_parsed = False self._event_type = EVENT_FEEDREADER self._feed_id = url - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, lambda _: self._update()) - self._init_regular_updates(hass) + + @callback + def async_setup(self) -> None: + """Set up the feed manager.""" + self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, self._async_update) + async_track_time_interval( + self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True + ) def _log_no_entries(self) -> None: """Send no entries log at debug level.""" _LOGGER.debug("No new entries to be published in feed %s", self._url) - def _init_regular_updates(self, hass: HomeAssistant) -> None: - """Schedule regular updates at the top of the clock.""" - track_time_interval( - hass, - lambda now: self._update(), - self._scan_interval, - cancel_on_shutdown=True, - ) - - @property - def last_update_successful(self) -> bool: - """Return True if the last feed update was successful.""" - return self._last_update_successful - - def _update(self) -> None: + async def _async_update(self, _: datetime | Event) -> None: """Update the feed and publish new entries to the event bus.""" - _LOGGER.info("Fetching new data from feed %s", self._url) + last_entry_timestamp = await self._hass.async_add_executor_job(self._update) + if last_entry_timestamp: + self._storage.async_put_timestamp(self._feed_id, last_entry_timestamp) + + def _update(self) -> struct_time | None: + """Update the feed and publish new entries to the event bus.""" + _LOGGER.debug("Fetching new data from feed %s", self._url) self._feed: feedparser.FeedParserDict = feedparser.parse( # type: ignore[no-redef] self._url, etag=None if not self._feed else self._feed.get("etag"), @@ -115,38 +123,41 @@ class FeedManager: ) if not self._feed: _LOGGER.error("Error fetching feed data from %s", self._url) - self._last_update_successful = False - else: - # The 'bozo' flag really only indicates that there was an issue - # during the initial parsing of the XML, but it doesn't indicate - # whether this is an unrecoverable error. In this case the - # feedparser lib is trying a less strict parsing approach. - # If an error is detected here, log warning message but continue - # processing the feed entries if present. - if self._feed.bozo != 0: - _LOGGER.warning( - "Possible issue parsing feed %s: %s", - self._url, - self._feed.bozo_exception, - ) - # Using etag and modified, if there's no new data available, - # the entries list will be empty - if self._feed.entries: - _LOGGER.debug( - "%s entri(es) available in feed %s", - len(self._feed.entries), - self._url, - ) - self._filter_entries() - self._publish_new_entries() - if self._has_published_parsed or self._has_updated_parsed: - self._storage.put_timestamp( - self._feed_id, cast(struct_time, self._last_entry_timestamp) - ) - else: - self._log_no_entries() - self._last_update_successful = True - _LOGGER.info("Fetch from feed %s completed", self._url) + return None + # The 'bozo' flag really only indicates that there was an issue + # during the initial parsing of the XML, but it doesn't indicate + # whether this is an unrecoverable error. In this case the + # feedparser lib is trying a less strict parsing approach. + # If an error is detected here, log warning message but continue + # processing the feed entries if present. + if self._feed.bozo != 0: + _LOGGER.warning( + "Possible issue parsing feed %s: %s", + self._url, + self._feed.bozo_exception, + ) + # Using etag and modified, if there's no new data available, + # the entries list will be empty + _LOGGER.debug( + "%s entri(es) available in feed %s", + len(self._feed.entries), + self._url, + ) + if not self._feed.entries: + self._log_no_entries() + return None + + self._filter_entries() + self._publish_new_entries() + + _LOGGER.debug("Fetch from feed %s completed", self._url) + + if ( + self._has_published_parsed or self._has_updated_parsed + ) and self._last_entry_timestamp: + return self._last_entry_timestamp + + return None def _filter_entries(self) -> None: """Filter the entries provided and return the ones to keep.""" @@ -196,7 +207,7 @@ class FeedManager: self._firstrun = False else: # Set last entry timestamp as epoch time if not available - self._last_entry_timestamp = datetime.utcfromtimestamp(0).timetuple() + self._last_entry_timestamp = dt_util.utc_from_timestamp(0).timetuple() for entry in self._feed.entries: if ( self._firstrun @@ -219,47 +230,62 @@ class FeedManager: class StoredData: - """Abstraction over pickle data storage.""" + """Represent a data storage.""" - def __init__(self, data_file: str) -> None: - """Initialize pickle data storage.""" - self._data_file = data_file - self._lock = Lock() - self._cache_outdated = True + def __init__(self, hass: HomeAssistant, legacy_data_file: str) -> None: + """Initialize data storage.""" + self._legacy_data_file = legacy_data_file self._data: dict[str, struct_time] = {} - self._fetch_data() + self._hass = hass + self._store: Store[dict[str, str]] = Store(hass, STORAGE_VERSION, DOMAIN) - def _fetch_data(self) -> None: - """Fetch data stored into pickle file.""" - if self._cache_outdated and exists(self._data_file): - try: - _LOGGER.debug("Fetching data from file %s", self._data_file) - with self._lock, open(self._data_file, "rb") as myfile: - self._data = pickle.load(myfile) or {} - self._cache_outdated = False - except Exception: # pylint: disable=broad-except - _LOGGER.error( - "Error loading data from pickled file %s", self._data_file - ) + async def async_setup(self) -> None: + """Set up storage.""" + if not os.path.exists(self._store.path): + # Remove the legacy store loading after deprecation period. + data = await self._hass.async_add_executor_job(self._legacy_fetch_data) + else: + if (store_data := await self._store.async_load()) is None: + return + # Make sure that dst is set to 0, by using gmtime() on the timestamp. + data = { + feed_id: gmtime(datetime.fromisoformat(timestamp_string).timestamp()) + for feed_id, timestamp_string in store_data.items() + } + + self._data = data + + def _legacy_fetch_data(self) -> dict[str, struct_time]: + """Fetch data stored in pickle file.""" + _LOGGER.debug("Fetching data from legacy file %s", self._legacy_data_file) + try: + with open(self._legacy_data_file, "rb") as myfile: + return pickle.load(myfile) or {} + except FileNotFoundError: + pass + except (OSError, pickle.PickleError) as err: + _LOGGER.error( + "Error loading data from pickled file %s: %s", + self._legacy_data_file, + err, + ) + + return {} def get_timestamp(self, feed_id: str) -> struct_time | None: - """Return stored timestamp for given feed id (usually the url).""" - self._fetch_data() + """Return stored timestamp for given feed id.""" return self._data.get(feed_id) - def put_timestamp(self, feed_id: str, timestamp: struct_time) -> None: - """Update timestamp for given feed id (usually the url).""" - self._fetch_data() - with self._lock, open(self._data_file, "wb") as myfile: - self._data.update({feed_id: timestamp}) - _LOGGER.debug( - "Overwriting feed %s timestamp in storage file %s: %s", - feed_id, - self._data_file, - timestamp, - ) - try: - pickle.dump(self._data, myfile) - except Exception: # pylint: disable=broad-except - _LOGGER.error("Error saving pickled data to %s", self._data_file) - self._cache_outdated = True + @callback + def async_put_timestamp(self, feed_id: str, timestamp: struct_time) -> None: + """Update timestamp for given feed id.""" + self._data[feed_id] = timestamp + self._store.async_delay_save(self._async_save_data, DELAY_SAVE) + + @callback + def _async_save_data(self) -> dict[str, str]: + """Save feed data to storage.""" + return { + feed_id: dt_util.utc_from_timestamp(timegm(struct_utc)).isoformat() + for feed_id, struct_utc in self._data.items() + } diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index dc7be9f1e69..86f25253c2d 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -26,7 +26,8 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from .const import CONF_IMPORT_PLUGINS, DOMAIN diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 43baa0e4efd..812a85b2f50 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -9,7 +9,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 3238fe91102..ca0deb89c7b 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -51,9 +51,6 @@ class FileNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a file.""" file: TextIO - if not self.hass.config.config_dir: - return - filepath: str = os.path.join(self.hass.config.config_dir, self.filename) with open(filepath, "a", encoding="utf8") as file: if os.stat(filepath).st_size == 0: diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 73f060e79b7..9d7cc99421f 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -9,10 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS +from .coordinator import FileSizeCoordinator -def _check_path(hass: HomeAssistant, path: str) -> None: - """Check if path is valid and allowed.""" +def _get_full_path(hass: HomeAssistant, path: str) -> str: + """Check if path is valid, allowed and return full path.""" get_path = pathlib.Path(path) if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") @@ -20,10 +21,17 @@ def _check_path(hass: HomeAssistant, path: str) -> None: if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + return str(get_path.absolute()) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) + full_path = await hass.async_add_executor_job( + _get_full_path, hass, entry.data[CONF_FILE_PATH] + ) + coordinator = FileSizeCoordinator(hass, full_path) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py new file mode 100644 index 00000000000..75411f84975 --- /dev/null +++ b/homeassistant/components/filesize/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for monitoring the size of a file.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +import os + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime]]): + """Filesize coordinator.""" + + def __init__(self, hass: HomeAssistant, path: str) -> None: + """Initialize filesize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=60), + always_update=False, + ) + self._path = path + + async def _async_update_data(self) -> dict[str, float | int | datetime]: + """Fetch file information.""" + try: + statinfo = await self.hass.async_add_executor_job(os.stat, self._path) + except OSError as error: + raise UpdateFailed(f"Can not retrieve file statistics {error}") from error + + size = statinfo.st_size + last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) + + _LOGGER.debug("size %s, last updated %s", size, last_updated) + data: dict[str, int | float | datetime] = { + "file": round(size / 1e6, 2), + "bytes": size, + "last_updated": last_updated, + } + + return data diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 0b5c39f3629..c8e5dae5892 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -1,9 +1,8 @@ """Sensor for monitoring the size of a file.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging -import os import pathlib from homeassistant.components.sensor import ( @@ -15,17 +14,12 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import FileSizeCoordinator _LOGGER = logging.getLogger(__name__) @@ -81,41 +75,6 @@ async def async_setup_entry( ) -class FileSizeCoordinator(DataUpdateCoordinator): - """Filesize coordinator.""" - - def __init__(self, hass: HomeAssistant, path: str) -> None: - """Initialize filesize coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=60), - ) - self._path = path - - async def _async_update_data(self) -> dict[str, float | int | datetime]: - """Fetch file information.""" - try: - statinfo = await self.hass.async_add_executor_job(os.stat, self._path) - except OSError as error: - raise UpdateFailed(f"Can not retrieve file statistics {error}") from error - - size = statinfo.st_size - last_updated = datetime.utcfromtimestamp(statinfo.st_mtime).replace( - tzinfo=dt_util.UTC - ) - - _LOGGER.debug("size %s, last updated %s", size, last_updated) - data: dict[str, int | float | datetime] = { - "file": round(size / 1e6, 2), - "bytes": size, - "last_updated": last_updated, - } - - return data - - class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity): """Filesize sensor.""" diff --git a/homeassistant/components/firmata/entity.py b/homeassistant/components/firmata/entity.py index 33e23f8d401..51d2ad51866 100644 --- a/homeassistant/components/firmata/entity.py +++ b/homeassistant/components/firmata/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .board import FirmataPinType from .const import DOMAIN, FIRMATA_MANUFACTURER diff --git a/homeassistant/components/fivem/__init__.py b/homeassistant/components/fivem/__init__.py index 93adda2b4fd..996aecef261 100644 --- a/homeassistant/components/fivem/__init__.py +++ b/homeassistant/components/fivem/__init__.py @@ -10,9 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - DOMAIN, -) +from .const import DOMAIN from .coordinator import FiveMDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] diff --git a/homeassistant/components/fivem/coordinator.py b/homeassistant/components/fivem/coordinator.py index e7fa4c426db..9da641b0bd9 100644 --- a/homeassistant/components/fivem/coordinator.py +++ b/homeassistant/components/fivem/coordinator.py @@ -10,10 +10,7 @@ from fivem import FiveM, FiveMServerOfflineError from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import ( - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_PLAYERS_LIST, diff --git a/homeassistant/components/fivem/entity.py b/homeassistant/components/fivem/entity.py index 53c35716276..c11378ff049 100644 --- a/homeassistant/components/fivem/entity.py +++ b/homeassistant/components/fivem/entity.py @@ -6,15 +6,11 @@ from dataclasses import dataclass import logging from typing import Any -from homeassistant.helpers.entity import DeviceInfo, EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, -) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DOMAIN, - MANUFACTURER, -) +from .const import DOMAIN, MANUFACTURER from .coordinator import FiveMDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index e867e624e8a..48d7809b715 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -19,11 +19,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DISPATCH_DETECTION, DOMAIN diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 8b641013eb4..41cdc0dbffe 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -13,7 +13,8 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/fjaraskupan/coordinator.py b/homeassistant/components/fjaraskupan/coordinator.py index 16e8157b094..f955c7ca024 100644 --- a/homeassistant/components/fjaraskupan/coordinator.py +++ b/homeassistant/components/fjaraskupan/coordinator.py @@ -15,7 +15,7 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index e19a0965524..142694a6bfb 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -15,7 +15,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.percentage import ( diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py index f4aa8c5a2dc..396f6b00e3b 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -8,7 +8,8 @@ from fjaraskupan import COMMAND_LIGHT_ON_OFF from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index 46c5f6db90b..d57e10aa561 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -5,7 +5,8 @@ from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py index e9bf84e0ed0..30527d4e29d 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -11,7 +11,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 838d2c934f9..b833617f2ca 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -16,8 +16,6 @@ from homeassistant.components.climate import ( from homeassistant.components.modbus import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, - CALL_TYPE_WRITE_REGISTER, - CONF_HUB, DEFAULT_HUB, ModbusHub, get_hub, @@ -34,6 +32,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +CALL_TYPE_WRITE_REGISTER = "write_register" +CONF_HUB = "hub" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, @@ -177,9 +178,7 @@ class Flexit(ClimateEntity): self, register_type: str, register: int ) -> int: """Read register using the Modbus hub slave.""" - result = await self._hub.async_pymodbus_call( - self._slave, register, 1, register_type - ) + result = await self._hub.async_pb_call(self._slave, register, 1, register_type) if result is None: _LOGGER.error("Error reading value from Flexit modbus adapter") return -1 @@ -197,7 +196,7 @@ class Flexit(ClimateEntity): return result / 10.0 async def _async_write_int16_to_register(self, register: int, value: int) -> bool: - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, register, value, CALL_TYPE_WRITE_REGISTER ) if not result: diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 5fac5cdb83a..557d0492320 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -2,7 +2,6 @@ import asyncio import logging -import async_timeout from pyflick.authentication import AuthException, SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET import voluptuous as vol @@ -45,7 +44,7 @@ class FlickConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): token = await auth.async_get_access_token() except asyncio.TimeoutError as err: raise CannotConnect() from err diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index a0844fe6cdb..8280e7b2fe0 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -1,9 +1,9 @@ """Support for Flick Electric Pricing data.""" +import asyncio from datetime import timedelta import logging from typing import Any -import async_timeout from pyflick import FlickAPI, FlickPrice from homeassistant.components.sensor import SensorEntity @@ -58,7 +58,7 @@ class FlickPricingSensor(SensorEntity): if self._price and self._price.end_at >= utcnow(): return # Power price data is still valid - async with async_timeout.timeout(60): + async with asyncio.timeout(60): self._price = await self._api.getPricing() _LOGGER.debug("Pricing data: %s", self._price) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 9eba3206720..81c21a4aa99 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -8,7 +8,8 @@ from flipr_api.exceptions import FliprError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 1b28a2552a2..99e86d4b6b5 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -1,12 +1,12 @@ """Flo device object.""" from __future__ import annotations +import asyncio from datetime import datetime, timedelta from typing import Any from aioflo.api import API from aioflo.errors import RequestError -from async_timeout import timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -39,7 +39,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update data via library.""" try: - async with timeout(20): + async with asyncio.timeout(20): await self.send_presence_ping() await self._update_device() await self._update_consumption_data() diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index e9d02432598..066ffef6a05 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -1,10 +1,8 @@ """Base entity class for Flo entities.""" from __future__ import annotations -from typing import Any - -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN as FLO_DOMAIN from .device import FloDeviceDataUpdateCoordinator @@ -27,7 +25,6 @@ class FloEntity(Entity): self._attr_unique_id = f"{device.mac_address}_{entity_type}" self._device: FloDeviceDataUpdateCoordinator = device - self._state: Any = None @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index f0aca366cfb..b2a0afdcb13 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -68,7 +68,6 @@ class FloDailyUsageSensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the daily water usage sensor.""" super().__init__("daily_consumption", device) - self._state: float = None @property def native_value(self) -> float | None: @@ -86,7 +85,6 @@ class FloSystemModeSensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the system mode sensor.""" super().__init__("current_system_mode", device) - self._state: str = None @property def native_value(self) -> str | None: @@ -107,7 +105,6 @@ class FloCurrentFlowRateSensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the flow rate sensor.""" super().__init__("current_flow_rate", device) - self._state: float = None @property def native_value(self) -> float | None: @@ -129,7 +126,6 @@ class FloTemperatureSensor(FloEntity, SensorEntity): super().__init__("temperature", device) if is_water: self._attr_translation_key = "water_temperature" - self._state: float = None @property def native_value(self) -> float | None: @@ -149,7 +145,6 @@ class FloHumiditySensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the humidity sensor.""" super().__init__("humidity", device) - self._state: float = None @property def native_value(self) -> float | None: @@ -170,7 +165,6 @@ class FloPressureSensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the pressure sensor.""" super().__init__("water_pressure", device) - self._state: float = None @property def native_value(self) -> float | None: @@ -190,7 +184,6 @@ class FloBatterySensor(FloEntity, SensorEntity): def __init__(self, device): """Initialize the battery sensor.""" super().__init__("battery", device) - self._state: float = None @property def native_value(self) -> float | None: diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index cd522ed177d..18a4341db57 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -73,12 +73,7 @@ class FloSwitch(FloEntity, SwitchEntity): def __init__(self, device: FloDeviceDataUpdateCoordinator) -> None: """Initialize the Flo switch.""" super().__init__("shutoff_valve", device) - self._state = self._device.last_known_valve_state == "open" - - @property - def is_on(self) -> bool: - """Return True if the valve is open.""" - return self._state + self._attr_is_on = device.last_known_valve_state == "open" @property def icon(self): @@ -90,19 +85,19 @@ class FloSwitch(FloEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Open the valve.""" await self._device.api_client.device.open_valve(self._device.id) - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Close the valve.""" await self._device.api_client.device.close_valve(self._device.id) - self._state = False + self._attr_is_on = False self.async_write_ha_state() @callback def async_update_state(self) -> None: """Retrieve the latest valve state and update the state machine.""" - self._state = self._device.last_known_valve_state == "open" + self._attr_is_on = self._device.last_known_valve_state == "open" self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 5ac340400af..3fdd54dd40d 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -5,7 +5,6 @@ import asyncio from http import HTTPStatus import logging -import async_timeout import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService @@ -49,7 +48,7 @@ class FlockNotificationService(BaseNotificationService): _LOGGER.debug("Attempting to call Flock at %s", self._url) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await self._session.post(self._url, json=payload) result = await response.json() diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index 70a99f56968..1f590b0cd16 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -93,8 +93,11 @@ class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): def _update_lists(self): """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.auth, read="true" + 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 ef63eeff1d7..f17e58529c4 100644 --- a/homeassistant/components/flume/entity.py +++ b/homeassistant/components/flume/entity.py @@ -3,7 +3,8 @@ from __future__ import annotations from typing import TypeVar -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py index 38c5ed70b5e..bf3f1dee94a 100644 --- a/homeassistant/components/flux_led/coordinator.py +++ b/homeassistant/components/flux_led/coordinator.py @@ -41,6 +41,7 @@ class FluxLedUpdateCoordinator(DataUpdateCoordinator[None]): request_refresh_debouncer=Debouncer( hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), + always_update=False, ) async def _async_update_data(self) -> None: diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py index 85600dd4dab..1adcd39e22f 100644 --- a/homeassistant/components/flux_led/entity.py +++ b/homeassistant/components/flux_led/entity.py @@ -20,8 +20,9 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_MINOR_VERSION, DOMAIN, SIGNAL_STATE_UPDATED diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index d1d812cb210..aa56708c645 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -27,7 +27,7 @@ "data": { "mode": "The chosen brightness mode.", "custom_effect_colors": "Custom Effect: List of 1 to 16 [R,G,B] colors. Example: [255,0,255],[60,128,0]", - "custom_effect_speed_pct": "Custom Effect: Speed in percents for the effect that switch colors.", + "custom_effect_speed_pct": "Custom Effect: Speed in percentage for the effects that switch colors.", "custom_effect_transition": "Custom Effect: Type of transition between the colors." } } diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index e10d9651c3b..c6d4236c219 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -5,12 +5,38 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import ( + CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, + CONF_MODULES_POWER, + DOMAIN, +) from .coordinator import ForecastSolarDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entry.""" + + if entry.version == 1: + new_options = entry.options.copy() + new_options |= { + CONF_MODULES_POWER: new_options.pop("modules power"), + CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, 0.0), + CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, 0.0), + } + + entry.version = 2 + + hass.config_entries.async_update_entry( + entry, data=entry.data, options=new_options + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Forecast.Solar from a config entry.""" coordinator = ForecastSolarDataUpdateCoordinator(hass, entry) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index e74585da35b..47e1afaec7b 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -14,7 +14,8 @@ from homeassistant.helpers import config_validation as cv from .const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -27,7 +28,7 @@ RE_API_KEY = re.compile(r"^[a-zA-Z0-9]{16}$") class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Forecast.Solar.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback @@ -127,8 +128,16 @@ class ForecastSolarOptionFlowHandler(OptionsFlow): default=self.config_entry.options[CONF_MODULES_POWER], ): vol.Coerce(int), vol.Optional( - CONF_DAMPING, - default=self.config_entry.options.get(CONF_DAMPING, 0.0), + CONF_DAMPING_MORNING, + default=self.config_entry.options.get( + CONF_DAMPING_MORNING, 0.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_DAMPING_EVENING, + default=self.config_entry.options.get( + CONF_DAMPING_EVENING, 0.0 + ), ): vol.Coerce(float), vol.Optional( CONF_INVERTER_SIZE, diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index e566733413b..24273f32405 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -8,6 +8,8 @@ LOGGER = logging.getLogger(__package__) CONF_DECLINATION = "declination" CONF_AZIMUTH = "azimuth" -CONF_MODULES_POWER = "modules power" +CONF_MODULES_POWER = "modules_power" CONF_DAMPING = "damping" +CONF_DAMPING_MORNING = "damping_morning" +CONF_DAMPING_EVENING = "damping_evening" CONF_INVERTER_SIZE = "inverter_size" diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index 273d3a49a2f..2ef6912e5a2 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -13,7 +13,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -48,7 +49,8 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): declination=entry.options[CONF_DECLINATION], azimuth=(entry.options[CONF_AZIMUTH] - 180), kwp=(entry.options[CONF_MODULES_POWER] / 1000), - damping=entry.options.get(CONF_DAMPING, 0), + damping_morning=entry.options.get(CONF_DAMPING_MORNING, 0.0), + damping_evening=entry.options.get(CONF_DAMPING_EVENING, 0.0), inverter=inverter_size, ) diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 1b511f03eda..7a2723ce591 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -18,8 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 43e6fca4ada..1413dba23d4 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -24,10 +24,11 @@ "data": { "api_key": "Forecast.Solar API Key (optional)", "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", - "damping": "Damping factor: adjusts the results in the morning and evening", + "damping_morning": "Damping factor: adjusts the results in the morning", + "damping_evening": "Damping factor: adjusts the results in the evening", "inverter_size": "Inverter size (Watt)", "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", - "modules power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" } } } diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 868ec8e1f9e..48c2be07c76 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -6,7 +6,6 @@ from collections import defaultdict import logging from typing import Any -import async_timeout from pyforked_daapd import ForkedDaapdAPI from pylibrespot_java import LibrespotJavaAPI @@ -667,7 +666,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._pause_requested = True await self.async_media_pause() try: - async with async_timeout.timeout(CALLBACK_TIMEOUT): + async with asyncio.timeout(CALLBACK_TIMEOUT): await self._paused_event.wait() # wait for paused except asyncio.TimeoutError: self._pause_requested = False @@ -762,7 +761,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): await sleep_future await self.api.add_to_queue(uris=media_id, playback="start", clear=True) try: - async with async_timeout.timeout(TTS_TIMEOUT): + async with asyncio.timeout(TTS_TIMEOUT): await self._tts_playing_event.wait() # we have started TTS, now wait for completion except asyncio.TimeoutError: diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index ae28fd8d111..384aea4c5fa 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -11,9 +11,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, 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 .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .const import ( + CONF_RTSP_PORT, + CONF_STREAM, + DOMAIN, + LOGGER, + SERVICE_PTZ, + SERVICE_PTZ_PRESET, +) DIR_UP = "up" DIR_DOWN = "down" @@ -94,12 +102,14 @@ async def async_setup_entry( class HassFoscamCamera(Camera): """An implementation of a Foscam IP camera.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, camera: FoscamCamera, config_entry: ConfigEntry) -> None: """Initialize a Foscam camera.""" super().__init__() self._foscam_session = camera - self._attr_name = config_entry.title self._username = config_entry.data[CONF_USERNAME] self._password = config_entry.data[CONF_PASSWORD] self._stream = config_entry.data[CONF_STREAM] @@ -107,6 +117,10 @@ class HassFoscamCamera(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/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 12463934adb..5465d524faf 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -7,10 +7,16 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + 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 @@ -43,6 +49,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) + 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 diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index aabd07366b4..10a151dbcf6 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -10,9 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - EntityCategory, -) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index 70db52cc127..e3a206b43a8 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -12,7 +12,6 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -72,13 +71,9 @@ class FreeboxButton(ButtonEntity): """Initialize a Freebox button.""" self.entity_description = description self._router = router + self._attr_device_info = router.device_info self._attr_unique_id = f"{router.mac} {description.name}" - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return self._router.device_info - async def async_press(self) -> None: """Press the button.""" await self.entity_description.async_press(self._router) diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index 9e833aca18b..fd11b949890 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -12,13 +12,13 @@ from homeassistant.components.ffmpeg.camera import ( FFmpegCamera, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_DETECTION, DOMAIN +from .const import ATTR_DETECTION, DOMAIN, FreeboxHomeCategory from .home_base import FreeboxHomeEntity from .router import FreeboxRouter @@ -50,7 +50,7 @@ def add_entities(hass: HomeAssistant, router, async_add_entities, tracked): new_tracked = [] for nodeid, node in router.home_devices.items(): - if (node["category"] != Platform.CAMERA) or (nodeid in tracked): + if (node["category"] != FreeboxHomeCategory.CAMERA) or (nodeid in tracked): continue new_tracked.append(FreeboxCamera(hass, router, node)) tracked.add(nodeid) diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index af641b5430c..2260e69cc3c 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure the Freebox integration.""" import logging +from typing import Any from freebox_api.exceptions import AuthorizationError, HttpRequestError import voluptuous as vol @@ -21,44 +22,36 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 def __init__(self) -> None: - """Initialize Freebox config flow.""" - self._host: str - self._port = None + """Initialize config flow.""" + self._data: dict[str, Any] = {} - def _show_setup_form(self, user_input=None, errors=None): - """Show the setup form to the user.""" - - if user_input is None: - user_input = {} - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, - vol.Required(CONF_PORT, default=user_input.get(CONF_PORT, "")): int, - } - ), - errors=errors or {}, - ) - - 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 a flow initiated by the user.""" - errors: dict[str, str] = {} - if user_input is None: - return self._show_setup_form(user_input, errors) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + } + ), + errors={}, + ) - self._host = user_input[CONF_HOST] - self._port = user_input[CONF_PORT] + self._data = user_input # Check if already configured - await self.async_set_unique_id(self._host) + await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() return await self.async_step_link() - async def async_step_link(self, user_input=None) -> FlowResult: + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the Freebox router. Given a configured host, will ask the user to press the button @@ -69,10 +62,10 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} - fbx = await get_api(self.hass, self._host) + fbx = await get_api(self.hass, self._data[CONF_HOST]) try: # Open connection and check authentication - await fbx.open(self._host, self._port) + await fbx.open(self._data[CONF_HOST], self._data[CONF_PORT]) # Check permissions await fbx.system.get_config() @@ -82,8 +75,8 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await fbx.close() return self.async_create_entry( - title=self._host, - data={CONF_HOST: self._host, CONF_PORT: self._port}, + title=self._data[CONF_HOST], + data=self._data, ) except AuthorizationError as error: @@ -91,18 +84,23 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "register_failed" except HttpRequestError: - _LOGGER.error("Error connecting to the Freebox router at %s", self._host) + _LOGGER.error( + "Error connecting to the Freebox router at %s", self._data[CONF_HOST] + ) errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Freebox router at %s", self._host + "Unknown error connecting with Freebox router at %s", + self._data[CONF_HOST], ) errors["base"] = "unknown" return self.async_show_form(step_id="link", errors=errors) - async def async_step_import(self, user_input=None) -> FlowResult: + 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) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 5a7c7863b4e..59dce75649b 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -1,6 +1,7 @@ """Freebox component constants.""" from __future__ import annotations +import enum import socket from homeassistant.const import Platform @@ -58,16 +59,30 @@ DEVICE_ICONS = { ATTR_DETECTION = "detection" +# Home +class FreeboxHomeCategory(enum.StrEnum): + """Freebox Home categories.""" + + ALARM = "alarm" + CAMERA = "camera" + DWS = "dws" + IOHOME = "iohome" + KFB = "kfb" + OPENER = "opener" + PIR = "pir" + RTS = "rts" + + CATEGORY_TO_MODEL = { - "pir": "F-HAPIR01A", - "camera": "F-HACAM01A", - "dws": "F-HADWS01A", - "kfb": "F-HAKFB01A", - "alarm": "F-MSEC07A", - "rts": "RTS", - "iohome": "IOHome", + FreeboxHomeCategory.PIR: "F-HAPIR01A", + FreeboxHomeCategory.CAMERA: "F-HACAM01A", + FreeboxHomeCategory.DWS: "F-HADWS01A", + FreeboxHomeCategory.KFB: "F-HAKFB01A", + FreeboxHomeCategory.ALARM: "F-MSEC07A", + FreeboxHomeCategory.RTS: "RTS", + FreeboxHomeCategory.IOHOME: "IOHome", } -HOME_COMPATIBLE_PLATFORMS = [ - Platform.CAMERA, +HOME_COMPATIBLE_CATEGORIES = [ + FreeboxHomeCategory.CAMERA, ] diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 7232f16696e..42e028b881e 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -61,9 +61,9 @@ class FreeboxDevice(ScannerEntity): self._name = device["primary_name"].strip() or DEFAULT_DEVICE_NAME self._mac = device["l2ident"]["id"] self._manufacturer = device["vendor_name"] - self._icon = icon_for_freebox_device(device) + self._attr_icon = icon_for_freebox_device(device) self._active = False - self._attrs: dict[str, Any] = {} + self._attr_extra_state_attributes: dict[str, Any] = {} @callback def async_update_state(self) -> None: @@ -72,7 +72,7 @@ class FreeboxDevice(ScannerEntity): self._active = device["active"] if device.get("attrs") is None: # device - self._attrs = { + self._attr_extra_state_attributes = { "last_time_reachable": datetime.fromtimestamp( device["last_time_reachable"] ), @@ -80,7 +80,7 @@ class FreeboxDevice(ScannerEntity): } else: # router - self._attrs = device["attrs"] + self._attr_extra_state_attributes = device["attrs"] @property def mac_address(self) -> str: @@ -102,16 +102,6 @@ class FreeboxDevice(ScannerEntity): """Return the source type.""" return SourceType.ROUTER - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the attributes.""" - return self._attrs - @callback def async_on_demand_update(self): """Update state.""" diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/home_base.py index c74f072a5be..d0bb8b10309 100644 --- a/homeassistant/components/freebox/home_base.py +++ b/homeassistant/components/freebox/home_base.py @@ -5,10 +5,11 @@ import logging from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity -from .const import CATEGORY_TO_MODEL, DOMAIN +from .const import CATEGORY_TO_MODEL, DOMAIN, FreeboxHomeCategory from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -47,10 +48,10 @@ class FreeboxHomeEntity(Entity): if self._model is None: if node["type"].get("inherit") == "node::rts": self._manufacturer = "Somfy" - self._model = CATEGORY_TO_MODEL.get("rts") + self._model = CATEGORY_TO_MODEL[FreeboxHomeCategory.RTS] elif node["type"].get("inherit") == "node::ios": self._manufacturer = "Somfy" - self._model = CATEGORY_TO_MODEL.get("iohome") + self._model = CATEGORY_TO_MODEL[FreeboxHomeCategory.IOHOME] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._id)}, @@ -125,7 +126,7 @@ class FreeboxHomeEntity(Entity): ) if not node: _LOGGER.warning( - "The Freebox Home device has no node for: " + ep_type + "/" + name + "The Freebox Home device has no node for: %s/%s", ep_type, name ) return None return node.get("value") diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index f5aa51983c6..7c83e980540 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -18,9 +18,8 @@ from freebox_api.exceptions import HttpRequestError, NotOpenError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.storage import Store from homeassistant.util import slugify @@ -29,7 +28,7 @@ from .const import ( APP_DESC, CONNECTION_SENSORS_KEYS, DOMAIN, - HOME_COMPATIBLE_PLATFORMS, + HOME_COMPATIBLE_CATEGORIES, STORAGE_KEY, STORAGE_VERSION, ) @@ -191,7 +190,7 @@ class FreeboxRouter: new_device = False for home_node in home_nodes: - if home_node["category"] in HOME_COMPATIBLE_PLATFORMS: + if home_node["category"] in HOME_COMPATIBLE_CATEGORIES: if self.home_devices.get(home_node["id"]) is None: new_device = True self.home_devices[home_node["id"]] = home_node diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 488d2d48f8c..901bfc63199 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -12,12 +12,13 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util from .const import DOMAIN +from .home_base import FreeboxHomeEntity from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) @@ -62,7 +63,7 @@ async def async_setup_entry( ) -> None: """Set up the sensors.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] - entities = [] + entities: list[SensorEntity] = [] _LOGGER.debug( "%s - %s - %s temperature sensors", @@ -98,7 +99,17 @@ async def async_setup_entry( for description in DISK_PARTITION_SENSORS ) - async_add_entities(entities, True) + for node in router.home_devices.values(): + for endpoint in node["show_endpoints"]: + if ( + endpoint["name"] == "battery" + and endpoint["ep_type"] == "signal" + and endpoint.get("value") is not None + ): + entities.append(FreeboxBatterySensor(hass, router, node, endpoint)) + + if entities: + async_add_entities(entities, True) class FreeboxSensor(SensorEntity): @@ -125,7 +136,7 @@ class FreeboxSensor(SensorEntity): self._attr_native_value = state @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() @@ -186,7 +197,6 @@ class FreeboxDiskSensor(FreeboxSensor): ) -> None: """Initialize a Freebox disk sensor.""" super().__init__(router, description) - self._disk = disk self._partition = partition self._attr_name = f"{partition['label']} {description.name}" self._attr_unique_id = ( @@ -213,3 +223,15 @@ class FreeboxDiskSensor(FreeboxSensor): self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 ) self._attr_native_value = value + + +class FreeboxBatterySensor(FreeboxHomeEntity, SensorEntity): + """Representation of a Freebox battery sensor.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + + @property + def native_value(self) -> int: + """Return the current state of the device.""" + return self.get_value("signal", "battery") diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index a0298a8bbd4..e7547b97d4e 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -48,8 +48,8 @@ class FreeboxSwitch(SwitchEntity): """Initialize the switch.""" self.entity_description = entity_description self._router = router - self._attr_device_info = self._router.device_info - self._attr_unique_id = f"{self._router.mac} {self.entity_description.name}" + 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): """Turn the switch on or off.""" diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index e6ac11889bc..e65856e03f4 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL @@ -76,7 +75,7 @@ async def _update_freedns(hass, session, url, auth_token): params[auth_token] = "" try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py index 9f397d32899..3bba3439341 100644 --- a/homeassistant/components/freedompro/binary_sensor.py +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 8a0a706c0d9..7a4b0473600 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index 59e58d75c43..b57acfacb4f 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index 68149b65fd7..59eb50ebe4a 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index 2a101d5c82a..9df3679ad70 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index e1e8ee44b2d..b1544d9b20d 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index 85d70c30956..dc6861a4f0a 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py index 7313be1920c..4de27c270b4 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 858bd74bb38..137aaa5ba2e 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -43,6 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ): raise ConfigEntryAuthFailed("Missing UPnP configuration") + await avm_wrapper.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = avm_wrapper @@ -51,8 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) - await avm_wrapper.async_config_entry_first_refresh() - # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index d76279a0f14..a4504996820 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -14,8 +14,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import AvmWrapper diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 531c05eea4a..69773778121 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -36,8 +36,9 @@ from homeassistant.helpers import ( entity_registry as er, update_coordinator, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -784,27 +785,20 @@ class AvmWrapper(FritzBoxTools): ) return result except FritzSecurityError: - _LOGGER.error( - ( - "Authorization Error: Please check the provided credentials and" - " verify that you can log into the web interface" - ), - exc_info=True, + _LOGGER.exception( + "Authorization Error: Please check the provided credentials and" + " verify that you can log into the web interface" ) except FRITZ_EXCEPTIONS: - _LOGGER.error( + _LOGGER.exception( "Service/Action Error: cannot execute service %s with action %s", service_name, action_name, - exc_info=True, ) except FritzConnectionException: - _LOGGER.error( - ( - "Connection Error: Please check the device is properly configured" - " for remote login" - ), - exc_info=True, + _LOGGER.exception( + "Connection Error: Please check the device is properly configured" + " for remote login" ) return {} diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 1352d9cb42e..026c0f3d6fb 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -9,9 +9,9 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index bd246dd914f..d199d2c5a2c 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase +from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -16,8 +17,9 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -35,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.async_add_executor_job(fritz.login) + except RequestConnectionError as err: + raise ConfigEntryNotReady from err except LoginError as err: raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index b9f94e24dc0..cc5457fb8a2 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -4,7 +4,7 @@ from pyfritzhome.devicetypes import FritzhomeTemplate from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzboxDataUpdateCoordinator, FritzBoxEntity diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 80087adf9ac..194825e602f 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -6,7 +6,7 @@ from datetime import timedelta from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome.devicetypes import FritzhomeTemplate -import requests +from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -51,9 +51,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat self.fritz.update_devices() if self.has_templates: self.fritz.update_templates() - except requests.exceptions.ConnectionError as ex: + except RequestConnectionError as ex: raise UpdateFailed from ex - except requests.exceptions.HTTPError: + except HTTPError: # If the device rebooted, login again try: self.fritz.login() diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 29df2f51a34..fdf38d88439 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,8 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.8"], + "quality_scale": "gold", + "requirements": ["pyfritzhome==0.6.9"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 46fa1a26561..013c1dfc7b5 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -48,6 +48,8 @@ class FritzSensorEntityDescription( ): """Description for Fritz!Smarthome sensor entities.""" + entity_category_fn: Callable[[FritzhomeDevice], EntityCategory | None] | None = None + def suitable_eco_temperature(device: FritzhomeDevice) -> bool: """Check suitablity for eco temperature sensor.""" @@ -74,6 +76,13 @@ def suitable_temperature(device: FritzhomeDevice) -> bool: return device.has_temperature_sensor and not device.has_thermostat +def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | None: + """Determine proper entity category for temperature sensor.""" + if device.has_switch or device.has_lightbulb: + return EntityCategory.DIAGNOSTIC + return None + + def value_nextchange_preset(device: FritzhomeDevice) -> str: """Return native value for next scheduled preset sensor.""" if device.nextchange_temperature == device.eco_temperature: @@ -94,7 +103,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, + entity_category_fn=entity_category_temperature, suitable=suitable_temperature, native_value=lambda device: device.temperature, ), @@ -224,3 +233,10 @@ class FritzBoxSensor(FritzBoxDeviceEntity, SensorEntity): def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.native_value(self.data) + + @property + def entity_category(self) -> EntityCategory | None: + """Return the category of the entity, if any.""" + if self.entity_description.entity_category_fn is not None: + return self.entity_description.entity_category_fn(self.data) + return super().entity_category diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index adf6bd3a35a..43cdb29f85f 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base import FritzBoxPhonebook diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 6202b945d97..793f381d52f 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant 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.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval from .const import ( diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index b65864ee089..4060731b21c 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -1,7 +1,7 @@ """Constants for the Fronius integration.""" from typing import Final, NamedTuple, TypedDict -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo DOMAIN: Final = "fronius" diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index ff949af0cba..6d5e43a94ee 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -24,8 +24,8 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 986dfd6ba52..50c557eae89 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==20230802.1"] + "requirements": ["home-assistant-frontend==20230906.1"] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 62df3a12c2b..641a267e987 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .browse_media import browse_node, browse_top_level @@ -39,7 +39,16 @@ async def async_setup_entry( afsapi: AFSAPI = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([AFSAPIDevice(config_entry.title, afsapi)], True) + async_add_entities( + [ + AFSAPIDevice( + config_entry.entry_id, + config_entry.title, + afsapi, + ) + ], + True, + ) class AFSAPIDevice(MediaPlayerEntity): @@ -67,15 +76,15 @@ class AFSAPIDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) - def __init__(self, name: str | None, afsapi: AFSAPI) -> None: + def __init__(self, unique_id: str, name: str | None, afsapi: AFSAPI) -> None: """Initialize the Frontier Silicon API device.""" self.fs_device = afsapi self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, afsapi.webfsapi_endpoint)}, + identifiers={(DOMAIN, unique_id)}, name=name, ) - + self._attr_unique_id = unique_id self._max_volume: int | None = None self.__modes_by_label: dict[str, str] | None = None @@ -114,8 +123,6 @@ class AFSAPIDevice(MediaPlayerEntity): ) self._attr_available = True - if not self._attr_name: - self._attr_name = await afsapi.get_friendly_name() if not self._attr_source_list: self.__modes_by_label = { diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index cdd7c7b276b..7d744214d93 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -6,7 +6,6 @@ import json from typing import Any from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError import voluptuous as vol @@ -42,7 +41,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - async with timeout(15): + async with asyncio.timeout(15): device_info = await fully.getDeviceInfo() except ( ClientConnectorError, diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 4e35d614587..0cfc15268b4 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -2,7 +2,6 @@ import asyncio from typing import Any, cast -from async_timeout import timeout from fullykiosk import FullyKiosk from fullykiosk.exceptions import FullyKioskError @@ -36,7 +35,7 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" try: - async with timeout(15): + async with asyncio.timeout(15): # Get device info and settings in parallel result = await asyncio.gather( self.fully.getDeviceInfo(), self.fully.getSettings() diff --git a/homeassistant/components/fully_kiosk/entity.py b/homeassistant/components/fully_kiosk/entity.py index d1f98c5afff..2fe367643ee 100644 --- a/homeassistant/components/fully_kiosk/entity.py +++ b/homeassistant/components/fully_kiosk/entity.py @@ -1,8 +1,8 @@ """Base entity for the Fully Kiosk Browser integration.""" from __future__ import annotations -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 63a17dbf285..82e0c832e7b 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -1,9 +1,9 @@ """The Garages Amsterdam integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout -from odp_amsterdam import ODPAmsterdam +from odp_amsterdam import ODPAmsterdam, VehicleType from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -40,12 +40,12 @@ async def get_coordinator( return hass.data[DOMAIN] async def async_get_garages(): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return { garage.garage_name: garage for garage in await ODPAmsterdam( session=aiohttp_client.async_get_clientsession(hass) - ).all_garages() + ).all_garages(vehicle=VehicleType.CAR) } coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index 5b444146624..ad0630249aa 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -8,13 +8,9 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from . import get_coordinator -from .const import ATTRIBUTION +from .entity import GaragesAmsterdamEntity BINARY_SENSORS = { "state", @@ -30,28 +26,18 @@ async def async_setup_entry( coordinator = await get_coordinator(hass) async_add_entities( - GaragesamsterdamBinarySensor( + GaragesAmsterdamBinarySensor( coordinator, config_entry.data["garage_name"], info_type ) for info_type in BINARY_SENSORS ) -class GaragesamsterdamBinarySensor(CoordinatorEntity, BinarySensorEntity): +class GaragesAmsterdamBinarySensor(GaragesAmsterdamEntity, BinarySensorEntity): """Binary Sensor representing garages amsterdam data.""" - _attr_attribution = ATTRIBUTION _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__( - self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str - ) -> None: - """Initialize garages amsterdam binary sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{garage_name}-{info_type}" - self._garage_name = garage_name - self._info_type = info_type - self._attr_name = garage_name + _attr_name = None @property def is_on(self) -> bool: diff --git a/homeassistant/components/garages_amsterdam/config_flow.py b/homeassistant/components/garages_amsterdam/config_flow.py index cd1591c9bc0..65a2d359747 100644 --- a/homeassistant/components/garages_amsterdam/config_flow.py +++ b/homeassistant/components/garages_amsterdam/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientResponseError -from odp_amsterdam import ODPAmsterdam +from odp_amsterdam import ODPAmsterdam, VehicleType import voluptuous as vol from homeassistant import config_entries @@ -32,7 +32,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: api_data = await ODPAmsterdam( session=aiohttp_client.async_get_clientsession(self.hass) - ).all_garages() + ).all_garages(vehicle=VehicleType.CAR) except ClientResponseError: _LOGGER.error("Unexpected response from server") return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/garages_amsterdam/entity.py b/homeassistant/components/garages_amsterdam/entity.py new file mode 100644 index 00000000000..45c85a101a9 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/entity.py @@ -0,0 +1,31 @@ +"""Generic entity for Garages Amsterdam.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTRIBUTION, DOMAIN + + +class GaragesAmsterdamEntity(CoordinatorEntity): + """Base Entity for garages amsterdam data.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str + ) -> None: + """Initialize garages amsterdam entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{garage_name}-{info_type}" + self._garage_name = garage_name + self._info_type = info_type + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, garage_name)}, + name=garage_name, + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index e2f068b961c..3f4ffc7fae1 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==5.1.0"] + "requirements": ["odp-amsterdam==5.3.1"] } diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index 252f010dfdb..a79ddc27379 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -5,13 +5,10 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_coordinator -from .const import ATTRIBUTION +from .entity import GaragesAmsterdamEntity SENSORS = { "free_space_short": "mdi:car", @@ -29,12 +26,12 @@ async def async_setup_entry( """Defer sensor setup to the shared sensor module.""" coordinator = await get_coordinator(hass) - entities: list[GaragesamsterdamSensor] = [] + entities: list[GaragesAmsterdamSensor] = [] for info_type in SENSORS: if getattr(coordinator.data[config_entry.data["garage_name"]], info_type) != "": entities.append( - GaragesamsterdamSensor( + GaragesAmsterdamSensor( coordinator, config_entry.data["garage_name"], info_type ) ) @@ -42,21 +39,17 @@ async def async_setup_entry( async_add_entities(entities) -class GaragesamsterdamSensor(CoordinatorEntity, SensorEntity): +class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity): """Sensor representing garages amsterdam data.""" - _attr_attribution = ATTRIBUTION _attr_native_unit_of_measurement = "cars" def __init__( self, coordinator: DataUpdateCoordinator, garage_name: str, info_type: str ) -> None: """Initialize garages amsterdam sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{garage_name}-{info_type}" - self._garage_name = garage_name - self._info_type = info_type - self._attr_name = f"{garage_name} - {info_type}".replace("_", " ") + super().__init__(coordinator, garage_name, info_type) + self._attr_translation_key = info_type self._attr_icon = SENSORS[info_type] @property diff --git a/homeassistant/components/garages_amsterdam/strings.json b/homeassistant/components/garages_amsterdam/strings.json index c8c3968aa59..89a85f97448 100644 --- a/homeassistant/components/garages_amsterdam/strings.json +++ b/homeassistant/components/garages_amsterdam/strings.json @@ -12,5 +12,21 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "free_space_short": { + "name": "Short parking free space" + }, + "free_space_long": { + "name": "Long parking free space" + }, + "short_capacity": { + "name": "Short parking capacity" + }, + "long_capacity": { + "name": "Long parking capacity" + } + } } } diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 2390f5af561..df41b0a1c43 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo import homeassistant.util.dt as dt_util from .const import DOMAIN diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index 0285f7bdf82..b66cb8cd00d 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from gardena_bluetooth.const import Valve +from gardena_bluetooth.const import Sensor, Valve from gardena_bluetooth.parse import CharacteristicBool from homeassistant.components.binary_sensor import ( @@ -26,6 +26,11 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + return {self.char.uuid} + DESCRIPTIONS = ( GardenaBluetoothBinarySensorEntityDescription( @@ -35,6 +40,13 @@ DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, char=Valve.connected_state, ), + GardenaBluetoothBinarySensorEntityDescription( + key=Sensor.connected_state.uuid, + translation_key="sensor_connected_state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + char=Sensor.connected_state, + ), ) @@ -44,7 +56,7 @@ async def async_setup_entry( """Set up binary sensor based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - GardenaBluetoothBinarySensor(coordinator, description) + GardenaBluetoothBinarySensor(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index b984d3420ae..1ed738a9690 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -6,10 +6,7 @@ from dataclasses import dataclass, field from gardena_bluetooth.const import Reset from gardena_bluetooth.parse import CharacteristicBool -from homeassistant.components.button import ( - ButtonEntity, - ButtonEntityDescription, -) +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -25,6 +22,11 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + return {self.char.uuid} + DESCRIPTIONS = ( GardenaBluetoothButtonEntityDescription( @@ -43,7 +45,7 @@ async def async_setup_entry( """Set up button based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities = [ - GardenaBluetoothButton(coordinator, description) + GardenaBluetoothButton(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index 3e981675057..7b34edd29af 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -5,9 +5,9 @@ import logging from typing import Any from gardena_bluetooth.client import Client -from gardena_bluetooth.const import DeviceInformation, ScanService +from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure -from gardena_bluetooth.parse import ManufacturerData, ProductGroup +from gardena_bluetooth.parse import ManufacturerData, ProductType import voluptuous as vol from homeassistant import config_entries @@ -34,7 +34,13 @@ def _is_supported(discovery_info: BluetoothServiceInfo): return False manufacturer_data = ManufacturerData.decode(data) - if manufacturer_data.group != ProductGroup.WATER_CONTROL: + product_type = ProductType.from_manufacturer_data(manufacturer_data) + + if product_type not in ( + ProductType.PUMP, + ProductType.VALVE, + ProductType.WATER_COMPUTER, + ): _LOGGER.debug("Unsupported device: %s", manufacturer_data) return False @@ -42,9 +48,11 @@ def _is_supported(discovery_info: BluetoothServiceInfo): def _get_name(discovery_info: BluetoothServiceInfo): - if discovery_info.name and discovery_info.name != discovery_info.address: - return discovery_info.name - return "Gardena Device" + data = discovery_info.manufacturer_data[ManufacturerData.company] + manufacturer_data = ManufacturerData.decode(data) + product_type = ProductType.from_manufacturer_data(manufacturer_data) + + return PRODUCT_NAMES.get(product_type, "Gardena Device") class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 9f5dc3223b5..73552e25c03 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -15,7 +15,8 @@ from gardena_bluetooth.parse import Characteristic, CharacteristicType from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -116,8 +117,12 @@ class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]): @property def available(self) -> bool: """Return if entity is available.""" - return super().available and bluetooth.async_address_present( - self.hass, self.coordinator.address, True + return ( + self.coordinator.last_update_success + and bluetooth.async_address_present( + self.hass, self.coordinator.address, True + ) + and self._attr_available ) @@ -125,9 +130,12 @@ class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): """Coordinator entity for entities with entity description.""" def __init__( - self, coordinator: Coordinator, description: EntityDescription + self, + coordinator: Coordinator, + description: EntityDescription, + context: set[str], ) -> None: """Initialize description entity.""" - super().__init__(coordinator, {description.key}) + super().__init__(coordinator, context) self._attr_unique_id = f"{coordinator.address}-{description.key}" self.entity_description = description diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 0226460d4d8..3e07eb1ad42 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena_bluetooth==1.0.2"] + "requirements": ["gardena_bluetooth==1.4.0"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index ec887458586..f0ba5dbd2fe 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from gardena_bluetooth.const import DeviceConfiguration, Valve +from gardena_bluetooth.const import DeviceConfiguration, Sensor, Valve from gardena_bluetooth.parse import ( + Characteristic, CharacteristicInt, CharacteristicLong, CharacteristicUInt16, @@ -16,7 +17,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -35,6 +36,15 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field( default_factory=lambda: CharacteristicInt("") ) + connected_state: Characteristic | None = None + + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + data = {self.char.uuid} + if self.connected_state: + data.add(self.connected_state.uuid) + return data DESCRIPTIONS = ( @@ -71,15 +81,27 @@ DESCRIPTIONS = ( char=DeviceConfiguration.rain_pause, ), GardenaBluetoothNumberEntityDescription( - key=DeviceConfiguration.season_pause.uuid, - translation_key="season_pause", + key=DeviceConfiguration.seasonal_adjust.uuid, + translation_key="seasonal_adjust", native_unit_of_measurement=UnitOfTime.DAYS, mode=NumberMode.BOX, - native_min_value=0.0, - native_max_value=365.0, + native_min_value=-128.0, + native_max_value=127.0, native_step=1.0, entity_category=EntityCategory.CONFIG, - char=DeviceConfiguration.season_pause, + char=DeviceConfiguration.seasonal_adjust, + ), + GardenaBluetoothNumberEntityDescription( + key=Sensor.threshold.uuid, + translation_key="sensor_threshold", + native_unit_of_measurement=PERCENTAGE, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + entity_category=EntityCategory.CONFIG, + char=Sensor.threshold, + connected_state=Sensor.connected_state, ), ) @@ -90,7 +112,7 @@ async def async_setup_entry( """Set up entity based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities: list[NumberEntity] = [ - GardenaBluetoothNumber(coordinator, description) + GardenaBluetoothNumber(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] @@ -110,6 +132,12 @@ class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity): self._attr_native_value = None else: self._attr_native_value = float(data) + + if char := self.entity_description.connected_state: + self._attr_available = bool(self.coordinator.get_cached(char)) + else: + self._attr_available = True + super()._handle_coordinator_update() async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index ebc83ae88af..396d8469ffc 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta -from gardena_bluetooth.const import Battery, Valve +from gardena_bluetooth.const import Battery, Sensor, Valve from gardena_bluetooth.parse import Characteristic from homeassistant.components.sensor import ( @@ -32,6 +32,15 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): """Description of entity.""" char: Characteristic = field(default_factory=lambda: Characteristic("")) + connected_state: Characteristic | None = None + + @property + def context(self) -> set[str]: + """Context needed for update coordinator.""" + data = {self.char.uuid} + if self.connected_state: + data.add(self.connected_state.uuid) + return data DESCRIPTIONS = ( @@ -51,6 +60,40 @@ DESCRIPTIONS = ( native_unit_of_measurement=PERCENTAGE, char=Battery.battery_level, ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.battery_level.uuid, + translation_key="sensor_battery_level", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + char=Sensor.battery_level, + connected_state=Sensor.connected_state, + ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.value.uuid, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.MOISTURE, + native_unit_of_measurement=PERCENTAGE, + char=Sensor.value, + connected_state=Sensor.connected_state, + ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.type.uuid, + translation_key="sensor_type", + entity_category=EntityCategory.DIAGNOSTIC, + char=Sensor.type, + connected_state=Sensor.connected_state, + ), + GardenaBluetoothSensorEntityDescription( + key=Sensor.measurement_timestamp.uuid, + translation_key="sensor_measurement_timestamp", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + char=Sensor.measurement_timestamp, + connected_state=Sensor.connected_state, + ), ) @@ -60,7 +103,7 @@ async def async_setup_entry( """Set up Gardena Bluetooth sensor based on a config entry.""" coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] entities: list[GardenaBluetoothEntity] = [ - GardenaBluetoothSensor(coordinator, description) + GardenaBluetoothSensor(coordinator, description, description.context) for description in DESCRIPTIONS if description.key in coordinator.characteristics ] @@ -81,6 +124,12 @@ class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity): tzinfo=dt_util.get_time_zone(self.hass.config.time_zone) ) self._attr_native_value = value + + if char := self.entity_description.connected_state: + self._attr_available = bool(self.coordinator.get_cached(char)) + else: + self._attr_available = True + super()._handle_coordinator_update() @@ -106,7 +155,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): super()._handle_coordinator_update() return - time = datetime.now(timezone.utc) + timedelta(seconds=value) + time = datetime.now(UTC) + timedelta(seconds=value) if not self._attr_native_value: self._attr_native_value = time super()._handle_coordinator_update() diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 1fc6e10b5a6..01eac80d1e0 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -23,6 +23,9 @@ "binary_sensor": { "valve_connected_state": { "name": "Valve connection" + }, + "sensor_connected_state": { + "name": "Sensor connection" } }, "button": { @@ -43,14 +46,26 @@ "rain_pause": { "name": "Rain pause" }, - "season_pause": { - "name": "Season pause" + "seasonal_adjust": { + "name": "Seasonal adjust" + }, + "sensor_threshold": { + "name": "Sensor threshold" } }, "sensor": { "activation_reason": { "name": "Activation reason" }, + "sensor_battery_level": { + "name": "Sensor battery" + }, + "sensor_type": { + "name": "Sensor type" + }, + "sensor_measurement_timestamp": { + "name": "Sensor timestamp" + }, "remaining_open_timestamp": { "name": "Valve closing" } diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 9474e006dbb..f25341455bb 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -13,10 +13,11 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, UnitOfLength, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -77,6 +78,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: }, ) ) + 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": "Global Disaster Alert and Coordination System", + }, + ) return True diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index e1535037d35..5d5589c54d6 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -1,6 +1,7 @@ """Feed Entity Manager Sensor support for GDACS Feed.""" from __future__ import annotations +from collections.abc import Callable import logging from homeassistant.components.sensor import SensorEntity @@ -33,21 +34,22 @@ async def async_setup_entry( ) -> None: """Set up the GDACS Feed platform.""" manager = hass.data[DOMAIN][FEED][entry.entry_id] - sensor = GdacsSensor(entry.entry_id, entry.unique_id, entry.title, manager) + sensor = GdacsSensor(entry, manager) async_add_entities([sensor]) - _LOGGER.debug("Sensor setup done") class GdacsSensor(SensorEntity): """Status sensor for the GDACS integration.""" _attr_should_poll = False + _attr_icon = DEFAULT_ICON + _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT - def __init__(self, config_entry_id, config_unique_id, config_title, manager): + def __init__(self, config_entry: ConfigEntry, manager) -> None: """Initialize entity.""" - self._config_entry_id = config_entry_id - self._config_unique_id = config_unique_id - self._config_title = config_title + self._config_entry_id = config_entry.entry_id + self._attr_unique_id = config_entry.unique_id + self._attr_name = f"GDACS ({config_entry.title})" self._manager = manager self._status = None self._last_update = None @@ -57,7 +59,7 @@ class GdacsSensor(SensorEntity): self._created = None self._updated = None self._removed = None - self._remove_signal_status = None + self._remove_signal_status: Callable[[], None] | None = None async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -112,26 +114,6 @@ class GdacsSensor(SensorEntity): """Return the state of the sensor.""" return self._total - @property - def unique_id(self) -> str | None: - """Return a unique ID containing latitude/longitude.""" - return self._config_unique_id - - @property - def name(self) -> str | None: - """Return the name of the entity.""" - return f"GDACS ({self._config_title})" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEFAULT_ICON - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return DEFAULT_UNIT_OF_MEASUREMENT - @property def extra_state_attributes(self): """Return the device state attributes.""" diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index c171c95e659..621566a70f5 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -172,15 +172,16 @@ class GenericCamera(Camera): self._last_url = None self._last_image = None + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return not self._still_image_url + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" if not self._still_image_url: - if not self.stream: - await self.async_create_stream() - if self.stream: - return await self.stream.async_get_image(width, height) return None try: url = self._still_image_url.async_render(parse_result=False) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index ec94d4c227c..67ff5a84ed9 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -1,6 +1,7 @@ """Config flow for generic (IP Camera).""" from __future__ import annotations +import asyncio from collections.abc import Mapping import contextlib from datetime import datetime @@ -10,9 +11,8 @@ import logging from typing import Any from aiohttp import web -from async_timeout import timeout from httpx import HTTPStatusError, RequestError, TimeoutException -import PIL +import PIL.Image import voluptuous as vol import yarl @@ -137,7 +137,7 @@ def get_image_type(image: bytes) -> str | None: imagefile = io.BytesIO(image) with contextlib.suppress(PIL.UnidentifiedImageError): img = PIL.Image.open(imagefile) - fmt = img.format.lower() + fmt = img.format.lower() if img.format else None if fmt is None: # if PIL can't figure it out, could be svg. @@ -171,7 +171,7 @@ async def async_test_still( auth = generate_auth(info) try: async_client = get_async_client(hass, verify_ssl=verify_ssl) - async with timeout(GET_IMAGE_TIMEOUT): + async with asyncio.timeout(GET_IMAGE_TIMEOUT): response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT) response.raise_for_status() image = response.content diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index d3d80747127..c9fcde87162 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -442,7 +442,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Update thermostat with latest state from sensor.""" try: cur_temp = float(state.state) - if math.isnan(cur_temp) or math.isinf(cur_temp): + if not math.isfinite(cur_temp): raise ValueError(f"Sensor has illegal state {state.state}") self._cur_temp = cur_temp except ValueError as ex: diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 5527f5ec9f1..f4ed94f1cf4 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -7,13 +7,7 @@ from typing import Final import voluptuous as vol from homeassistant.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE -from homeassistant.core import ( - CALLBACK_TYPE, - HassJob, - HomeAssistant, - State, - callback, -) +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.config_validation import entity_domain from homeassistant.helpers.event import ( diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index d1be775e370..541d2e0b89d 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -10,8 +10,7 @@ from geocachingapi.models import GeocachingStatus from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 66cbbcbd67e..f0159915d32 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -4,8 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 2b56a9f6cbb..3cdf48944fd 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -1,11 +1,11 @@ """The GIOS component.""" from __future__ import annotations +import asyncio import logging from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from gios import Gios from gios.exceptions import GiosError from gios.model import GiosSensors @@ -88,7 +88,7 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): async def _async_update_data(self) -> GiosSensors: """Update data via library.""" try: - async with timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): return await self.gios.async_update() except (GiosError, ClientConnectorError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index a1b4abd2dc7..ffc34bd2b78 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError import voluptuous as vol @@ -37,7 +36,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): websession = async_get_clientsession(self.hass) - async with timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): gios = Gios(user_input[CONF_STATION_ID], websession) await gios.async_update() diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 64119436230..f5bbdb06198 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -18,8 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index edcdd8c057b..d497700f5db 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index e952164792f..cd9c3a9135d 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/goalzero/entity.py b/homeassistant/components/goalzero/entity.py index eef6ea43d9c..d72d1557a03 100644 --- a/homeassistant/components/goalzero/entity.py +++ b/homeassistant/components/goalzero/entity.py @@ -4,7 +4,8 @@ from goalzero import Yeti from homeassistant.const import ATTR_MODEL, CONF_NAME from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN, MANUFACTURER diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index a6b2f344ab3..ba1426e1201 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index b5872ed3dea..02c1d5beac7 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import ( CONF_MODEL_FAMILY, diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index 4ac61c59e58..12cad42547d 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -10,7 +10,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER @@ -34,7 +34,7 @@ class GoodweButtonEntityDescription( SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription( key="synchronize_clock", - name="Synchronize inverter clock", + translation_key="synchronize_clock", icon="mdi:clock-check-outline", entity_category=EntityCategory.CONFIG, action=lambda inv: inv.write_setting("time", datetime.now()), @@ -66,6 +66,7 @@ class GoodweButtonEntity(ButtonEntity): """Entity representing the inverter clock synchronization button.""" _attr_should_poll = False + _attr_has_entity_name = True entity_description: GoodweButtonEntityDescription def __init__( diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 3f9714aa372..a3e4190f309 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER @@ -45,10 +45,12 @@ def _get_setting_unit(inverter: Inverter, setting: str) -> str: NUMBERS = ( + # Only one of the export limits are added. + # Availability is checked in the filter method. # Export limit in W GoodweNumberEntityDescription( key="grid_export_limit", - name="Grid export limit", + translation_key="grid_export_limit", icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.POWER, @@ -63,7 +65,7 @@ NUMBERS = ( # Export limit in % GoodweNumberEntityDescription( key="grid_export_limit", - name="Grid export limit", + translation_key="grid_export_limit", icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -76,7 +78,7 @@ NUMBERS = ( ), GoodweNumberEntityDescription( key="battery_discharge_depth", - name="Depth of discharge (on-grid)", + translation_key="battery_discharge_depth", icon="mdi:battery-arrow-down", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, @@ -120,6 +122,7 @@ class InverterNumberEntity(NumberEntity): """Inverter numeric setting entity.""" _attr_should_poll = False + _attr_has_entity_name = True entity_description: GoodweNumberEntityDescription def __init__( diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index bfaef5d537a..bc22376e4d9 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -7,7 +7,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER @@ -31,7 +31,6 @@ _OPTION_TO_MODE: dict[str, OperationMode] = { OPERATION_MODE = SelectEntityDescription( key="operation_mode", - name="Inverter operation mode", icon="mdi:solar-power", entity_category=EntityCategory.CONFIG, translation_key="operation_mode", @@ -72,6 +71,7 @@ class InverterOperationModeEntity(SelectEntity): """Entity representing the inverter operation mode.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 4a4296bc526..332280bac5a 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/goodwe/strings.json b/homeassistant/components/goodwe/strings.json index 28765c005af..ec4ea80e22a 100644 --- a/homeassistant/components/goodwe/strings.json +++ b/homeassistant/components/goodwe/strings.json @@ -18,8 +18,22 @@ } }, "entity": { + "button": { + "synchronize_clock": { + "name": "Synchronize inverter clock" + } + }, + "number": { + "grid_export_limit": { + "name": "Grid export limit" + }, + "battery_discharge_depth": { + "name": "Depth of discharge (on-grid)" + } + }, "select": { "operation_mode": { + "name": "Inverter operation mode", "state": { "general": "General mode", "off_grid": "Off grid mode", diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 347e8444946..9559a06d49c 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -36,7 +36,7 @@ from homeassistant.components.calendar import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity import generate_entity_id @@ -383,7 +383,6 @@ class GoogleCalendarEntity( self._event: CalendarEvent | None = None self._attr_name = data[CONF_NAME].capitalize() self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) - self._offset_value: timedelta | None = None self.entity_id = entity_id self._attr_unique_id = unique_id self._attr_entity_registry_enabled_default = entity_enabled @@ -392,17 +391,6 @@ class GoogleCalendarEntity( CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT ) - @property - def should_poll(self) -> bool: - """Enable polling for the entity. - - The coordinator is not used by multiple entities, but instead - is used to poll the calendar API at a separate interval from the - entity state updates itself which happen more frequently (e.g. to - fire an alarm when the next event starts). - """ - return True - @property def extra_state_attributes(self) -> dict[str, bool]: """Return the device state attributes.""" @@ -411,16 +399,16 @@ class GoogleCalendarEntity( @property def offset_reached(self) -> bool: """Return whether or not the event offset was reached.""" - if self._event and self._offset_value: - return is_offset_reached( - self._event.start_datetime_local, self._offset_value - ) + (event, offset_value) = self._event_with_offset() + if event is not None and offset_value is not None: + return is_offset_reached(event.start_datetime_local, offset_value) return False @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + (event, _) = self._event_with_offset() + return event def _event_filter(self, event: Event) -> bool: """Return True if the event is visible.""" @@ -435,12 +423,10 @@ class GoogleCalendarEntity( # We do not ask for an update with async_add_entities() # because it will update disabled entities. This is started as a # task to let if sync in the background without blocking startup - async def refresh() -> None: - await self.coordinator.async_request_refresh() - self._apply_coordinator_update() - self.coordinator.config_entry.async_create_background_task( - self.hass, refresh(), "google.calendar-refresh" + self.hass, + self.coordinator.async_request_refresh(), + "google.calendar-refresh", ) async def async_get_events( @@ -453,8 +439,10 @@ class GoogleCalendarEntity( for event in filter(self._event_filter, result_items) ] - def _apply_coordinator_update(self) -> None: - """Copy state from the coordinator to this entity.""" + def _event_with_offset( + self, + ) -> tuple[CalendarEvent | None, timedelta | None]: + """Get the calendar event and offset if any.""" if api_event := next( filter( self._event_filter, @@ -462,27 +450,13 @@ class GoogleCalendarEntity( ), None, ): - self._event = _get_calendar_event(api_event) - (self._event.summary, self._offset_value) = extract_offset( - self._event.summary, self._offset - ) - else: - self._event = None - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._apply_coordinator_update() - super()._handle_coordinator_update() - - async def async_update(self) -> None: - """Disable update behavior. - - This relies on the coordinator callback update to write home assistant - state with the next calendar event. This update is a no-op as no new data - fetch is needed to evaluate the state to determine if the next event has - started, handled by CalendarEntity parent class. - """ + event = _get_calendar_event(api_event) + if self._offset: + (event.summary, offset_value) = extract_offset( + event.summary, self._offset + ) + return event, offset_value + return None, None async def async_create_event(self, **kwargs: Any) -> None: """Add a new event to calendar.""" diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 47681308b53..94c97357b85 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -6,7 +6,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 109ea61dbab..5248ce7c4da 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -147,6 +147,6 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig def unsub_all(): unsub() if unsub_pending: - unsub_pending() # pylint: disable=not-callable + unsub_pending() return unsub_all diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 36660820efb..425a394b522 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -28,8 +28,16 @@ from homeassistant.components import ( switch, vacuum, ) +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.camera import CameraEntityFeature +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.fan import FanEntityFeature +from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING -from homeassistant.components.media_player import MediaType +from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_BATTERY_LEVEL, @@ -302,7 +310,7 @@ class CameraStreamTrait(_Trait): def supported(domain, features, device_class, _): """Test if state is supported.""" if domain == camera.DOMAIN: - return features & camera.SUPPORT_STREAM + return features & CameraEntityFeature.STREAM return False @@ -612,7 +620,7 @@ class LocatorTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_LOCATE + return domain == vacuum.DOMAIN and features & VacuumEntityFeature.LOCATE def sync_attributes(self): """Return locator attributes for a sync request.""" @@ -652,7 +660,7 @@ class EnergyStorageTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain == vacuum.DOMAIN and features & vacuum.SUPPORT_BATTERY + return domain == vacuum.DOMAIN and features & VacuumEntityFeature.BATTERY def sync_attributes(self): """Return EnergyStorage attributes for a sync request.""" @@ -710,7 +718,7 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return True - if domain == cover.DOMAIN and features & cover.SUPPORT_STOP: + if domain == cover.DOMAIN and features & CoverEntityFeature.STOP: return True return False @@ -721,7 +729,7 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return { "pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & vacuum.SUPPORT_PAUSE + & VacuumEntityFeature.PAUSE != 0 } if domain == cover.DOMAIN: @@ -991,7 +999,7 @@ class TemperatureSettingTrait(_Trait): response["thermostatHumidityAmbient"] = current_humidity if operation in (climate.HVACMode.AUTO, climate.HVACMode.HEAT_COOL): - if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: + if supported & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: response["thermostatTemperatureSetpointHigh"] = round( TemperatureConverter.convert( attrs[climate.ATTR_TARGET_TEMP_HIGH], @@ -1093,7 +1101,7 @@ class TemperatureSettingTrait(_Trait): supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) svc_data = {ATTR_ENTITY_ID: self.state.entity_id} - if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: + if supported & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: svc_data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high svc_data[climate.ATTR_TARGET_TEMP_LOW] = temp_low else: @@ -1311,11 +1319,11 @@ class ArmDisArmTrait(_Trait): } state_to_support = { - STATE_ALARM_ARMED_HOME: alarm_control_panel.const.SUPPORT_ALARM_ARM_HOME, - STATE_ALARM_ARMED_AWAY: alarm_control_panel.const.SUPPORT_ALARM_ARM_AWAY, - STATE_ALARM_ARMED_NIGHT: alarm_control_panel.const.SUPPORT_ALARM_ARM_NIGHT, - STATE_ALARM_ARMED_CUSTOM_BYPASS: alarm_control_panel.const.SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - STATE_ALARM_TRIGGERED: alarm_control_panel.const.SUPPORT_ALARM_TRIGGER, + STATE_ALARM_ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME, + STATE_ALARM_ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY, + STATE_ALARM_ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT, + STATE_ALARM_ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + STATE_ALARM_TRIGGERED: AlarmControlPanelEntityFeature.TRIGGER, } @staticmethod @@ -1454,9 +1462,9 @@ class FanSpeedTrait(_Trait): def supported(domain, features, device_class, _): """Test if state is supported.""" if domain == fan.DOMAIN: - return features & fan.SUPPORT_SET_SPEED + return features & FanEntityFeature.SET_SPEED if domain == climate.DOMAIN: - return features & climate.SUPPORT_FAN_MODE + return features & ClimateEntityFeature.FAN_MODE return False def sync_attributes(self): @@ -1468,7 +1476,7 @@ class FanSpeedTrait(_Trait): if domain == fan.DOMAIN: reversible = bool( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & fan.SUPPORT_DIRECTION + & FanEntityFeature.DIRECTION ) result.update( @@ -1604,7 +1612,7 @@ class ModesTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == fan.DOMAIN and features & fan.SUPPORT_PRESET_MODE: + if domain == fan.DOMAIN and features & FanEntityFeature.PRESET_MODE: return True if domain == input_select.DOMAIN: @@ -1613,16 +1621,16 @@ class ModesTrait(_Trait): if domain == select.DOMAIN: return True - if domain == humidifier.DOMAIN and features & humidifier.SUPPORT_MODES: + if domain == humidifier.DOMAIN and features & HumidifierEntityFeature.MODES: return True - if domain == light.DOMAIN and features & light.SUPPORT_EFFECT: + if domain == light.DOMAIN and features & LightEntityFeature.EFFECT: return True if domain != media_player.DOMAIN: return False - return features & media_player.SUPPORT_SELECT_SOUND_MODE + return features & MediaPlayerEntityFeature.SELECT_SOUND_MODE def _generate(self, name, settings): """Generate a list of modes.""" @@ -1812,7 +1820,7 @@ class InputSelectorTrait(_Trait): def supported(domain, features, device_class, _): """Test if state is supported.""" if domain == media_player.DOMAIN and ( - features & media_player.SUPPORT_SELECT_SOURCE + features & MediaPlayerEntityFeature.SELECT_SOURCE ): return True @@ -1910,13 +1918,13 @@ class OpenCloseTrait(_Trait): response["discreteOnlyOpenClose"] = True elif ( self.state.domain == cover.DOMAIN - and features & cover.SUPPORT_SET_POSITION == 0 + and features & CoverEntityFeature.SET_POSITION == 0 ): response["discreteOnlyOpenClose"] = True if ( - features & cover.SUPPORT_OPEN == 0 - and features & cover.SUPPORT_CLOSE == 0 + features & CoverEntityFeature.OPEN == 0 + and features & CoverEntityFeature.CLOSE == 0 ): response["queryOnlyOpenClose"] = True @@ -1985,7 +1993,7 @@ class OpenCloseTrait(_Trait): elif position == 100: service = cover.SERVICE_OPEN_COVER should_verify = True - elif features & cover.SUPPORT_SET_POSITION: + elif features & CoverEntityFeature.SET_POSITION: service = cover.SERVICE_SET_COVER_POSITION if position > 0: should_verify = True @@ -2026,7 +2034,8 @@ class VolumeTrait(_Trait): """Test if trait is supported.""" if domain == media_player.DOMAIN: return features & ( - media_player.SUPPORT_VOLUME_SET | media_player.SUPPORT_VOLUME_STEP + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP ) return False @@ -2035,7 +2044,9 @@ class VolumeTrait(_Trait): """Return volume attributes for a sync request.""" features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) return { - "volumeCanMuteAndUnmute": bool(features & media_player.SUPPORT_VOLUME_MUTE), + "volumeCanMuteAndUnmute": bool( + features & MediaPlayerEntityFeature.VOLUME_MUTE + ), "commandOnlyVolume": self.state.attributes.get(ATTR_ASSUMED_STATE, False), # Volume amounts in SET_VOLUME and VOLUME_RELATIVE are on a scale # from 0 to this value. @@ -2078,7 +2089,7 @@ class VolumeTrait(_Trait): if not ( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & media_player.SUPPORT_VOLUME_SET + & MediaPlayerEntityFeature.VOLUME_SET ): raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported") @@ -2088,13 +2099,13 @@ class VolumeTrait(_Trait): relative = params["relativeSteps"] features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & media_player.SUPPORT_VOLUME_SET: + if features & MediaPlayerEntityFeature.VOLUME_SET: current = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) target = max(0.0, min(1.0, current + relative / 100)) await self._set_volume_absolute(data, target) - elif features & media_player.SUPPORT_VOLUME_STEP: + elif features & MediaPlayerEntityFeature.VOLUME_STEP: svc = media_player.SERVICE_VOLUME_UP if relative < 0: svc = media_player.SERVICE_VOLUME_DOWN @@ -2116,7 +2127,7 @@ class VolumeTrait(_Trait): if not ( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & media_player.SUPPORT_VOLUME_MUTE + & MediaPlayerEntityFeature.VOLUME_MUTE ): raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported") @@ -2158,14 +2169,14 @@ def _verify_pin_challenge(data, state, challenge): MEDIA_COMMAND_SUPPORT_MAPPING = { - COMMAND_MEDIA_NEXT: media_player.SUPPORT_NEXT_TRACK, - COMMAND_MEDIA_PAUSE: media_player.SUPPORT_PAUSE, - COMMAND_MEDIA_PREVIOUS: media_player.SUPPORT_PREVIOUS_TRACK, - COMMAND_MEDIA_RESUME: media_player.SUPPORT_PLAY, - COMMAND_MEDIA_SEEK_RELATIVE: media_player.SUPPORT_SEEK, - COMMAND_MEDIA_SEEK_TO_POSITION: media_player.SUPPORT_SEEK, - COMMAND_MEDIA_SHUFFLE: media_player.SUPPORT_SHUFFLE_SET, - COMMAND_MEDIA_STOP: media_player.SUPPORT_STOP, + COMMAND_MEDIA_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, + COMMAND_MEDIA_PAUSE: MediaPlayerEntityFeature.PAUSE, + COMMAND_MEDIA_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, + COMMAND_MEDIA_RESUME: MediaPlayerEntityFeature.PLAY, + COMMAND_MEDIA_SEEK_RELATIVE: MediaPlayerEntityFeature.SEEK, + COMMAND_MEDIA_SEEK_TO_POSITION: MediaPlayerEntityFeature.SEEK, + COMMAND_MEDIA_SHUFFLE: MediaPlayerEntityFeature.SHUFFLE_SET, + COMMAND_MEDIA_STOP: MediaPlayerEntityFeature.STOP, } MEDIA_COMMAND_ATTRIBUTES = { @@ -2350,7 +2361,7 @@ class ChannelTrait(_Trait): """Test if state is supported.""" if ( domain == media_player.DOMAIN - and (features & media_player.SUPPORT_PLAY_MEDIA) + and (features & MediaPlayerEntityFeature.PLAY_MEDIA) and device_class == media_player.MediaPlayerDeviceClass.TV ): return True diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 4a294489c97..24b71dd0180 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -1,6 +1,8 @@ """Support for Google Assistant SDK.""" from __future__ import annotations +import dataclasses + import aiohttp from gassist_text import TextAssistant from google.oauth2.credentials import Credentials @@ -9,7 +11,12 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery, intent from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -101,19 +108,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_service(hass: HomeAssistant) -> None: """Add the services for Google Assistant SDK.""" - async def send_text_command(call: ServiceCall) -> None: + async def send_text_command(call: ServiceCall) -> ServiceResponse: """Send a text command to Google Assistant SDK.""" commands: list[str] = call.data[SERVICE_SEND_TEXT_COMMAND_FIELD_COMMAND] media_players: list[str] | None = call.data.get( SERVICE_SEND_TEXT_COMMAND_FIELD_MEDIA_PLAYER ) - await async_send_text_commands(hass, commands, media_players) + command_response_list = await async_send_text_commands( + hass, commands, media_players + ) + if call.return_response: + return { + "responses": [ + dataclasses.asdict(command_response) + for command_response in command_response_list + ] + } + return None hass.services.async_register( DOMAIN, SERVICE_SEND_TEXT_COMMAND, send_text_command, schema=SERVICE_SEND_TEXT_COMMAND_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, ) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index 1d89e208ced..5ae39c98f3c 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -1,6 +1,7 @@ """Helper classes for Google Assistant SDK integration.""" from __future__ import annotations +from dataclasses import dataclass from http import HTTPStatus import logging from typing import Any @@ -48,9 +49,16 @@ DEFAULT_LANGUAGE_CODES = { } +@dataclass +class CommandResponse: + """Response from a single command to Google Assistant Service.""" + + text: str + + async def async_send_text_commands( hass: HomeAssistant, commands: list[str], media_players: list[str] | None = None -) -> None: +) -> list[CommandResponse]: """Send text commands to Google Assistant Service.""" # There can only be 1 entry (config_flow has single_instance_allowed) entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] @@ -68,6 +76,7 @@ async def async_send_text_commands( with TextAssistant( credentials, language_code, audio_out=bool(media_players) ) as assistant: + command_response_list = [] for command in commands: resp = assistant.assist(command) text_response = resp[0] @@ -91,6 +100,8 @@ async def async_send_text_commands( }, blocking=True, ) + command_response_list.append(CommandResponse(text_response)) + return command_response_list def default_language_code(hass: HomeAssistant): diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index c8f6869f6e4..720c7d9aa2b 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -3,7 +3,6 @@ import asyncio import logging import os -import async_timeout from google.cloud import texttospeech import voluptuous as vol @@ -286,7 +285,7 @@ class GoogleCloudTTSProvider(Provider): "input": synthesis_input, } - async with async_timeout.timeout(10): + async with asyncio.timeout(10): assert self.hass response = await self.hass.async_add_executor_job( self._client.synthesize_speech, request diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index c7f7e632bd6..52dcdb61e8f 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -4,7 +4,6 @@ from datetime import timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME @@ -69,7 +68,7 @@ async def _update_google_domains(hass, session, domain, user, password, timeout) params = {"hostname": domain} try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/google_mail/config_flow.py b/homeassistant/components/google_mail/config_flow.py index 0552f57bf5c..b57947302cc 100644 --- a/homeassistant/components/google_mail/config_flow.py +++ b/homeassistant/components/google_mail/config_flow.py @@ -60,9 +60,7 @@ class OAuth2FlowHandler( def _get_profile() -> str: """Get profile from inside the executor.""" - users = build( # pylint: disable=no-member - "gmail", "v1", credentials=credentials - ).users() + users = build("gmail", "v1", credentials=credentials).users() return users.getProfile(userId="me").execute()["emailAddress"] credentials = Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/google_mail/entity.py b/homeassistant/components/google_mail/entity.py index 5e447125e82..fed8ff481f0 100644 --- a/homeassistant/components/google_mail/entity.py +++ b/homeassistant/components/google_mail/entity.py @@ -1,8 +1,8 @@ """Entity representing a Google Mail account.""" from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from .api import AsyncConfigEntryAuth from .const import DOMAIN, MANUFACTURER diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index a65e845095c..dc1ee33c16e 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -1,7 +1,7 @@ """Support for Google Mail Sensors.""" from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from googleapiclient.http import HttpRequest @@ -46,7 +46,7 @@ class GoogleMailSensor(GoogleMailEntity, SensorEntity): data: dict = await self.hass.async_add_executor_job(settings.execute) if data["enableAutoReply"] and (end := data.get("endTime")): - value = datetime.fromtimestamp(int(end) / 1000, tz=timezone.utc) + value = datetime.fromtimestamp(int(end) / 1000, tz=UTC) else: value = None self._attr_native_value = value diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index ffbc4ff3cfd..06a50dab854 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -16,8 +16,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.location import find_coordinates import homeassistant.util.dt as dt_util @@ -71,15 +70,21 @@ class GoogleTravelTimeSensor(SensorEntity): """Representation of a Google travel time sensor.""" _attr_attribution = ATTRIBUTION + _attr_native_unit_of_measurement = UnitOfTime.MINUTES def __init__(self, config_entry, name, api_key, origin, destination, client): """Initialize the sensor.""" - self._name = name + self._attr_name = name + self._attr_unique_id = config_entry.entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, api_key)}, + name=DOMAIN, + ) + self._config_entry = config_entry - self._unit_of_measurement = UnitOfTime.MINUTES self._matrix = None self._api_key = api_key - self._unique_id = config_entry.entry_id self._client = client self._origin = origin self._destination = destination @@ -108,25 +113,6 @@ class GoogleTravelTimeSensor(SensorEntity): return round(_data["duration"]["value"] / 60) return None - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._api_key)}, - name=DOMAIN, - ) - - @property - def unique_id(self) -> str: - """Return unique ID of entity.""" - return self._unique_id - - @property - def name(self): - """Get the name of the sensor.""" - return self._name - @property def extra_state_attributes(self): """Return the state attributes.""" @@ -148,11 +134,6 @@ class GoogleTravelTimeSensor(SensorEntity): res["destination"] = self._resolved_destination return res - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - async def first_update(self, _=None): """Run the first update and write the state.""" await self.hass.async_add_executor_job(self.update) diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index b2da37bdf7e..cbef769bdc9 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -110,7 +110,9 @@ async def async_setup_entry( GoveeBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class GoveeBluetoothSensorEntity( diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 4cce9290a68..278d6571cb7 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -9,8 +9,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 8a53e3b3229..17d915feadb 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -37,9 +37,8 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -116,40 +115,33 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE ) + _attr_target_temperature_step = TARGET_TEMPERATURE_STEP + _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF] + _attr_preset_modes = PRESET_MODES + _attr_fan_modes = [*FAN_MODES_REVERSE] + _attr_swing_modes = SWING_MODES def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" super().__init__(coordinator) - self._name = coordinator.device.device_info.name - self._mac = coordinator.device.device_info.mac - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique id for the device.""" - return self._mac - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - identifiers={(DOMAIN, self._mac)}, + self._attr_name = coordinator.device.device_info.name + mac = coordinator.device.device_info.mac + self._attr_unique_id = mac + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, mac)}, manufacturer="Gree", - name=self._name, + name=self._attr_name, ) - - @property - def temperature_unit(self) -> str: - """Return the temperature units for the device.""" units = self.coordinator.device.temperature_units if units == TemperatureUnits.C: - return UnitOfTemperature.CELSIUS - return UnitOfTemperature.FAHRENHEIT + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_min_temp = TEMP_MIN + self._attr_max_temp = TEMP_MAX + else: + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_min_temp = TEMP_MIN_F + self._attr_max_temp = TEMP_MAX_F @property def current_temperature(self) -> float: @@ -170,32 +162,13 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _LOGGER.debug( "Setting temperature to %d for %s", temperature, - self._name, + self._attr_name, ) self.coordinator.device.target_temperature = round(temperature) await self.coordinator.push_state_update() self.async_write_ha_state() - @property - def min_temp(self) -> float: - """Return the minimum temperature supported by the device.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return TEMP_MIN - return TEMP_MIN_F - - @property - def max_temp(self) -> float: - """Return the maximum temperature supported by the device.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return TEMP_MAX - return TEMP_MAX_F - - @property - def target_temperature_step(self) -> float: - """Return the target temperature step support by the device.""" - return TARGET_TEMPERATURE_STEP - @property def hvac_mode(self) -> HVACMode | None: """Return the current HVAC mode for the device.""" @@ -212,7 +185,7 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _LOGGER.debug( "Setting HVAC mode to %s for device %s", hvac_mode, - self._name, + self._attr_name, ) if hvac_mode == HVACMode.OFF: @@ -230,7 +203,7 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE async def async_turn_on(self) -> None: """Turn on the device.""" - _LOGGER.debug("Turning on HVAC for device %s", self._name) + _LOGGER.debug("Turning on HVAC for device %s", self._attr_name) self.coordinator.device.power = True await self.coordinator.push_state_update() @@ -238,19 +211,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE async def async_turn_off(self) -> None: """Turn off the device.""" - _LOGGER.debug("Turning off HVAC for device %s", self._name) + _LOGGER.debug("Turning off HVAC for device %s", self._attr_name) self.coordinator.device.power = False await self.coordinator.push_state_update() self.async_write_ha_state() - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the HVAC modes support by the device.""" - modes = [*HVAC_MODES_REVERSE] - modes.append(HVACMode.OFF) - return modes - @property def preset_mode(self) -> str: """Return the current preset mode for the device.""" @@ -272,7 +238,7 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _LOGGER.debug( "Setting preset mode to %s for device %s", preset_mode, - self._name, + self._attr_name, ) self.coordinator.device.steady_heat = False @@ -292,11 +258,6 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE await self.coordinator.push_state_update() self.async_write_ha_state() - @property - def preset_modes(self) -> list[str]: - """Return the preset modes support by the device.""" - return PRESET_MODES - @property def fan_mode(self) -> str | None: """Return the current fan mode for the device.""" @@ -312,11 +273,6 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE await self.coordinator.push_state_update() self.async_write_ha_state() - @property - def fan_modes(self) -> list[str]: - """Return the fan modes support by the device.""" - return [*FAN_MODES_REVERSE] - @property def swing_mode(self) -> str: """Return the current swing mode for the device.""" @@ -339,7 +295,7 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _LOGGER.debug( "Setting swing mode to %s for device %s", swing_mode, - self._name, + self._attr_name, ) self.coordinator.device.horizontal_swing = HorizontalSwing.Center @@ -351,8 +307,3 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE await self.coordinator.push_state_update() self.async_write_ha_state() - - @property - def swing_modes(self) -> list[str]: - """Return the swing modes currently supported for this device.""" - return SWING_MODES diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index 66be66f9dc9..fd1b80ef90d 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -1,6 +1,5 @@ """Entity object for shared properties of Gree entities.""" -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .bridge import DeviceDataUpdateCoordinator @@ -14,25 +13,13 @@ class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Initialize the entity.""" super().__init__(coordinator) self._desc = desc - self._name = f"{coordinator.device.device_info.name}" - self._mac = coordinator.device.device_info.mac - - @property - def name(self): - """Return the name of the node.""" - return f"{self._name} {self._desc}" - - @property - def unique_id(self): - """Return the unique id based for the node.""" - return f"{self._mac}_{self._desc}" - - @property - def device_info(self) -> DeviceInfo: - """Return info about the device.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._mac)}, - identifiers={(DOMAIN, self._mac)}, + name = coordinator.device.device_info.name + mac = coordinator.device.device_info.mac + self._attr_name = f"{name} {desc}" + self._attr_unique_id = f"{mac}_{desc}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, mac)}, manufacturer="Gree", - name=self._name, + name=name, ) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 33df9822ac2..ef011c4308a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import abstractmethod import asyncio -from collections.abc import Collection, Iterable +from collections.abc import Callable, Collection, Iterable, Mapping from contextvars import ContextVar import logging from typing import Any, Protocol, cast @@ -473,9 +473,60 @@ class GroupEntity(Entity): """Representation of a Group of entities.""" _attr_should_poll = False + _entity_ids: list[str] + + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + for entity_id in self._entity_ids: + if (state := self.hass.states.get(entity_id)) is None: + continue + self.async_update_supported_features(entity_id, state) + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + if event: + self.async_update_supported_features( + event.data["entity_id"], event.data["new_state"] + ) + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) async def async_added_to_hass(self) -> None: """Register listeners.""" + for entity_id in self._entity_ids: + if (state := self.hass.states.get(entity_id)) is None: + continue + self.async_update_supported_features(entity_id, state) + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: + """Handle child updates.""" + self.async_set_context(event.context) + self.async_update_supported_features( + event.data["entity_id"], event.data["new_state"] + ) + self.async_defer_or_update_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) async def _update_at_start(_: HomeAssistant) -> None: self.async_update_group_state() @@ -493,9 +544,18 @@ class GroupEntity(Entity): self.async_write_ha_state() @abstractmethod + @callback def async_update_group_state(self) -> None: """Abstract method to update the entity.""" + @callback + def async_update_supported_features( + self, + entity_id: str, + new_state: State | None, + ) -> None: + """Update dictionaries with supported features.""" + class Group(Entity): """Track a group of entity ids.""" diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 7415ee8c60d..d1e91db8f86 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,6 +1,8 @@ """Platform allowing several binary sensor to be grouped into one binary sensor.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -24,11 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity @@ -89,6 +87,20 @@ async def async_setup_entry( ) +@callback +def async_create_preview_binary_sensor( + name: str, validated_config: dict[str, Any] +) -> BinarySensorGroup: + """Create a preview sensor.""" + return BinarySensorGroup( + None, + name, + None, + validated_config[CONF_ENTITIES], + validated_config[CONF_ALL], + ) + + class BinarySensorGroup(GroupEntity, BinarySensorEntity): """Representation of a BinarySensorGroup.""" @@ -100,7 +112,7 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): name: str, device_class: BinarySensorDeviceClass | None, entity_ids: list[str], - mode: str | None, + mode: bool | None, ) -> None: """Initialize a BinarySensorGroup entity.""" super().__init__() @@ -113,25 +125,6 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): if mode: self.mode = all - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - @callback def async_update_group_state(self) -> None: """Query all members and determine the binary sensor group state.""" diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 6cdc47f9e85..93160b0db5b 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -7,8 +7,10 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.const import CONF_ENTITIES, CONF_TYPE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -19,9 +21,17 @@ from homeassistant.helpers.schema_config_entry_flow import ( entity_selector_without_own_entities, ) -from . import DOMAIN -from .binary_sensor import CONF_ALL +from . import DOMAIN, GroupEntity +from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC +from .cover import async_create_preview_cover +from .event import async_create_preview_event +from .fan import async_create_preview_fan +from .light import async_create_preview_light +from .lock import async_create_preview_lock +from .media_player import MediaPlayerGroup, async_create_preview_media_player +from .sensor import async_create_preview_sensor +from .switch import async_create_preview_switch _STATISTIC_MEASURES = [ "min", @@ -36,15 +46,22 @@ _STATISTIC_MEASURES = [ async def basic_group_options_schema( - domain: str | list[str], handler: SchemaCommonFlowHandler + domain: str | list[str], handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" + if handler is None: + entity_selector = selector.selector( + {"entity": {"domain": domain, "multiple": True}} + ) + else: + entity_selector = entity_selector_without_own_entities( + cast(SchemaOptionsFlowHandler, handler.parent_handler), + selector.EntitySelectorConfig(domain=domain, multiple=True), + ) + return vol.Schema( { - vol.Required(CONF_ENTITIES): entity_selector_without_own_entities( - cast(SchemaOptionsFlowHandler, handler.parent_handler), - selector.EntitySelectorConfig(domain=domain, multiple=True), - ), + vol.Required(CONF_ENTITIES): entity_selector, vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(), } ) @@ -63,7 +80,9 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema: ) -async def binary_sensor_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: +async def binary_sensor_options_schema( + handler: SchemaCommonFlowHandler | None, +) -> vol.Schema: """Generate options schema.""" return (await basic_group_options_schema("binary_sensor", handler)).extend( { @@ -96,7 +115,7 @@ SENSOR_OPTIONS = { async def sensor_options_schema( - domain: str, handler: SchemaCommonFlowHandler + domain: str, handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" return ( @@ -110,7 +129,7 @@ SENSOR_CONFIG_SCHEMA = basic_group_config_schema( async def light_switch_options_schema( - domain: str, handler: SchemaCommonFlowHandler + domain: str, handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" return (await basic_group_options_schema(domain, handler)).extend( @@ -125,6 +144,7 @@ async def light_switch_options_schema( GROUP_TYPES = [ "binary_sensor", "cover", + "event", "fan", "light", "lock", @@ -159,34 +179,47 @@ CONFIG_FLOW = { "user": SchemaFlowMenuStep(GROUP_TYPES), "binary_sensor": SchemaFlowFormStep( BINARY_SENSOR_CONFIG_SCHEMA, + preview="group", validate_user_input=set_group_type("binary_sensor"), ), "cover": SchemaFlowFormStep( basic_group_config_schema("cover"), + preview="group", validate_user_input=set_group_type("cover"), ), + "event": SchemaFlowFormStep( + basic_group_config_schema("event"), + preview="group", + validate_user_input=set_group_type("event"), + ), "fan": SchemaFlowFormStep( basic_group_config_schema("fan"), + preview="group", validate_user_input=set_group_type("fan"), ), "light": SchemaFlowFormStep( basic_group_config_schema("light"), + preview="group", validate_user_input=set_group_type("light"), ), "lock": SchemaFlowFormStep( basic_group_config_schema("lock"), + preview="group", validate_user_input=set_group_type("lock"), ), "media_player": SchemaFlowFormStep( basic_group_config_schema("media_player"), + preview="group", validate_user_input=set_group_type("media_player"), ), "sensor": SchemaFlowFormStep( SENSOR_CONFIG_SCHEMA, + preview="group", validate_user_input=set_group_type("sensor"), ), "switch": SchemaFlowFormStep( basic_group_config_schema("switch"), + preview="group", validate_user_input=set_group_type("switch"), ), } @@ -194,16 +227,59 @@ CONFIG_FLOW = { OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), - "binary_sensor": SchemaFlowFormStep(binary_sensor_options_schema), - "cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")), - "fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")), - "light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")), - "lock": SchemaFlowFormStep(partial(basic_group_options_schema, "lock")), - "media_player": SchemaFlowFormStep( - partial(basic_group_options_schema, "media_player") + "binary_sensor": SchemaFlowFormStep( + binary_sensor_options_schema, + preview="group", ), - "sensor": SchemaFlowFormStep(partial(sensor_options_schema, "sensor")), - "switch": SchemaFlowFormStep(partial(light_switch_options_schema, "switch")), + "cover": SchemaFlowFormStep( + partial(basic_group_options_schema, "cover"), + preview="group", + ), + "event": SchemaFlowFormStep( + partial(basic_group_options_schema, "event"), + preview="group", + ), + "fan": SchemaFlowFormStep( + partial(basic_group_options_schema, "fan"), + preview="group", + ), + "light": SchemaFlowFormStep( + partial(light_switch_options_schema, "light"), + preview="group", + ), + "lock": SchemaFlowFormStep( + partial(basic_group_options_schema, "lock"), + preview="group", + ), + "media_player": SchemaFlowFormStep( + partial(basic_group_options_schema, "media_player"), + preview="group", + ), + "sensor": SchemaFlowFormStep( + partial(sensor_options_schema, "sensor"), + preview="group", + ), + "switch": SchemaFlowFormStep( + partial(light_switch_options_schema, "switch"), + preview="group", + ), +} + +PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {} + +CREATE_PREVIEW_ENTITY: dict[ + str, + Callable[[str, dict[str, Any]], GroupEntity | MediaPlayerGroup], +] = { + "binary_sensor": async_create_preview_binary_sensor, + "cover": async_create_preview_cover, + "event": async_create_preview_event, + "fan": async_create_preview_fan, + "light": async_create_preview_light, + "lock": async_create_preview_lock, + "media_player": async_create_preview_media_player, + "sensor": async_create_preview_sensor, + "switch": async_create_preview_switch, } @@ -241,6 +317,21 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): ) _async_hide_members(hass, options[CONF_ENTITIES], hidden_by) + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + for group_type, form_step in OPTIONS_FLOW.items(): + if group_type not in GROUP_TYPES: + continue + schema = cast( + Callable[ + [SchemaCommonFlowHandler | None], Coroutine[Any, Any, vol.Schema] + ], + form_step.schema, + ) + PREVIEW_OPTIONS_SCHEMA[group_type] = await schema(None) + websocket_api.async_register_command(hass, ws_start_preview) + def _async_hide_members( hass: HomeAssistant, members: list[str], hidden_by: er.RegistryEntryHider | None @@ -253,3 +344,59 @@ def _async_hide_members( if entity_id not in registry.entities: continue registry.async_update_entity(entity_id, hidden_by=hidden_by) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "group/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@callback +def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + entity_registry_entry: er.RegistryEntry | None = None + if msg["flow_type"] == "config_flow": + flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) + group_type = flow_status["step_id"] + form_step = cast(SchemaFlowFormStep, CONFIG_FLOW[group_type]) + schema = cast(vol.Schema, form_step.schema) + validated = schema(msg["user_input"]) + name = validated["name"] + else: + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry_id = flow_status["handler"] + config_entry = hass.config_entries.async_get_entry(config_entry_id) + if not config_entry: + raise HomeAssistantError + group_type = config_entry.options["group_type"] + name = config_entry.options["name"] + validated = PREVIEW_OPTIONS_SCHEMA[group_type](msg["user_input"]) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + if entries: + entity_registry_entry = entries[0] + + @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 = CREATE_PREVIEW_ENTITY[group_type](name, validated) + preview_entity.hass = hass + preview_entity.registry_entry = entity_registry_entry + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( + async_preview_updated + ) diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 784ac9a94af..d22184c0922 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -17,7 +17,6 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -41,14 +40,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import attribute_equal, reduce_attribute +from .util import reduce_attribute KEY_OPEN_CLOSE = "open_close" KEY_STOP = "stop" @@ -100,6 +95,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_cover( + name: str, validated_config: dict[str, Any] +) -> CoverGroup: + """Create a preview sensor.""" + return CoverGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" @@ -108,11 +115,10 @@ class CoverGroup(GroupEntity, CoverEntity): _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False _attr_current_cover_position: int | None = 100 - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a CoverGroup entity.""" - self._entities = entities + self._entity_ids = entities self._covers: dict[str, set[str]] = { KEY_OPEN_CLOSE: set(), KEY_STOP: set(), @@ -128,21 +134,11 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} self._attr_unique_id = unique_id - @callback - def _update_supported_features_event( - self, event: EventType[EventStateChangedData] - ) -> None: - self.async_set_context(event.context) - self.async_update_supported_features( - event.data["entity_id"], event.data["new_state"] - ) - @callback def async_update_supported_features( self, entity_id: str, new_state: State | None, - update_state: bool = True, ) -> None: """Update dictionaries with supported features.""" if not new_state: @@ -150,8 +146,6 @@ class CoverGroup(GroupEntity, CoverEntity): values.discard(entity_id) for values in self._tilts.values(): values.discard(entity_id) - if update_state: - self.async_defer_or_update_ha_state() return features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -182,25 +176,6 @@ class CoverGroup(GroupEntity, CoverEntity): else: self._tilts[KEY_POSITION].discard(entity_id) - if update_state: - self.async_defer_or_update_ha_state() - - async def async_added_to_hass(self) -> None: - """Register listeners.""" - for entity_id in self._entities: - if (new_state := self.hass.states.get(entity_id)) is None: - continue - self.async_update_supported_features( - entity_id, new_state, update_state=False - ) - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entities, self._update_supported_features_event - ) - ) - - await super().async_added_to_hass() - async def async_open_cover(self, **kwargs: Any) -> None: """Move the covers up.""" data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} @@ -274,11 +249,9 @@ class CoverGroup(GroupEntity, CoverEntity): @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False - states = [ state.state - for entity_id in self._entities + for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ] @@ -292,7 +265,7 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_is_closed = True self._attr_is_closing = False self._attr_is_opening = False - for entity_id in self._entities: + for entity_id in self._entity_ids: if not (state := self.hass.states.get(entity_id)): continue if state.state == STATE_OPEN: @@ -316,9 +289,6 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_current_cover_position = reduce_attribute( position_states, ATTR_CURRENT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - position_states, ATTR_CURRENT_POSITION - ) tilt_covers = self._tilts[KEY_POSITION] all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] @@ -326,9 +296,6 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_current_cover_tilt_position = reduce_attribute( tilt_states, ATTR_CURRENT_TILT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - tilt_states, ATTR_CURRENT_TILT_POSITION - ) supported_features = CoverEntityFeature(0) if self._covers[KEY_OPEN_CLOSE]: @@ -345,11 +312,3 @@ class CoverGroup(GroupEntity, CoverEntity): if self._tilts[KEY_POSITION]: supported_features |= CoverEntityFeature.SET_TILT_POSITION self._attr_supported_features = supported_features - - if not self._attr_assumed_state: - for entity_id in self._entities: - if (state := self.hass.states.get(entity_id)) is None: - continue - if state and state.attributes.get(ATTR_ASSUMED_STATE): - self._attr_assumed_state = True - break diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py new file mode 100644 index 00000000000..ca0c88867fe --- /dev/null +++ b/homeassistant/components/group/event.py @@ -0,0 +1,193 @@ +"""Platform allowing several event entities to be grouped into one event.""" +from __future__ import annotations + +import itertools +from typing import Any + +import voluptuous as vol + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN, + PLATFORM_SCHEMA, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType + +from . import GroupEntity + +DEFAULT_NAME = "Event group" + +# No limit on parallel updates to enable a group calling another group +PARALLEL_UPDATES = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +async def async_setup_platform( + _: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + __: DiscoveryInfoType | None = None, +) -> None: + """Set up the event group platform.""" + async_add_entities( + [ + EventGroup( + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config[CONF_ENTITIES], + ) + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize event group config entry.""" + registry = er.async_get(hass) + entities = er.async_validate_entity_ids( + registry, config_entry.options[CONF_ENTITIES] + ) + async_add_entities( + [ + EventGroup( + config_entry.entry_id, + config_entry.title, + entities, + ) + ] + ) + + +@callback +def async_create_preview_event( + name: str, validated_config: dict[str, Any] +) -> EventGroup: + """Create a preview sensor.""" + return EventGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + +class EventGroup(GroupEntity, EventEntity): + """Representation of an event group.""" + + _attr_available = False + _attr_should_poll = False + + def __init__( + self, + unique_id: str | None, + name: str, + entity_ids: list[str], + ) -> None: + """Initialize an event group.""" + self._entity_ids = entity_ids + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + self._attr_event_types = [] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: + """Handle child updates.""" + if not self.hass.is_running: + return + + self.async_set_context(event.context) + + # Update all properties of the group + self.async_update_group_state() + + # Re-fire if one of the members fires an event, but only + # if the original state was not unavailable or unknown. + if ( + (old_state := event.data["old_state"]) + and old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and (new_state := event.data["new_state"]) + and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and (event_type := new_state.attributes.get(ATTR_EVENT_TYPE)) + ): + event_attributes = new_state.attributes.copy() + + # We should not propagate the event properties as + # fired event attributes. + del event_attributes[ATTR_EVENT_TYPE] + del event_attributes[ATTR_EVENT_TYPES] + event_attributes.pop(ATTR_DEVICE_CLASS, None) + event_attributes.pop(ATTR_FRIENDLY_NAME, None) + + # Fire the group event + self._trigger_event(event_type, event_attributes) + + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + await super().async_added_to_hass() + + @callback + def async_update_group_state(self) -> None: + """Query all members and determine the event group properties.""" + states = [ + state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] + + # None of the members are available + if not states: + self._attr_available = False + return + + # Gather and combine all possible event types from all entities + self._attr_event_types = list( + set( + itertools.chain.from_iterable( + state.attributes.get(ATTR_EVENT_TYPES, []) for state in states + ) + ) + ) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 1fcb859f926..4e3bb824266 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -25,7 +25,6 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -38,19 +37,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import ( - attribute_equal, - most_frequent_attribute, - reduce_attribute, - states_equal, -) +from .util import attribute_equal, most_frequent_attribute, reduce_attribute SUPPORTED_FLAGS = { FanEntityFeature.SET_SPEED, @@ -100,15 +90,24 @@ async def async_setup_entry( async_add_entities([FanGroup(config_entry.entry_id, config_entry.title, entities)]) +@callback +def async_create_preview_fan(name: str, validated_config: dict[str, Any]) -> FanGroup: + """Create a preview sensor.""" + return FanGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" _attr_available: bool = False - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a FanGroup entity.""" - self._entities = entities + self._entity_ids = entities self._fans: dict[int, set[str]] = {flag: set() for flag in SUPPORTED_FLAGS} self._percentage = None self._oscillating = None @@ -144,21 +143,11 @@ class FanGroup(GroupEntity, FanEntity): """Return whether or not the fan is currently oscillating.""" return self._oscillating - @callback - def _update_supported_features_event( - self, event: EventType[EventStateChangedData] - ) -> None: - self.async_set_context(event.context) - self.async_update_supported_features( - event.data["entity_id"], event.data["new_state"] - ) - @callback def async_update_supported_features( self, entity_id: str, new_state: State | None, - update_state: bool = True, ) -> None: """Update dictionaries with supported features.""" if not new_state: @@ -172,25 +161,6 @@ class FanGroup(GroupEntity, FanEntity): else: self._fans[feature].discard(entity_id) - if update_state: - self.async_defer_or_update_ha_state() - - async def async_added_to_hass(self) -> None: - """Register listeners.""" - for entity_id in self._entities: - if (new_state := self.hass.states.get(entity_id)) is None: - continue - self.async_update_supported_features( - entity_id, new_state, update_state=False - ) - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entities, self._update_supported_features_event - ) - ) - - await super().async_added_to_hass() - async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan, as a percentage.""" if percentage == 0: @@ -250,7 +220,7 @@ class FanGroup(GroupEntity, FanEntity): await self.hass.services.async_call( DOMAIN, service, - {ATTR_ENTITY_ID: self._entities}, + {ATTR_ENTITY_ID: self._entity_ids}, blocking=True, context=self._context, ) @@ -266,19 +236,16 @@ class FanGroup(GroupEntity, FanEntity): """Set an attribute based on most frequent supported entities attributes.""" states = self._async_states_by_support_flag(flag) setattr(self, attr, most_frequent_attribute(states, entity_attr)) - self._attr_assumed_state |= not attribute_equal(states, entity_attr) @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False states = [ state - for entity_id in self._entities + for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ] - self._attr_assumed_state |= not states_equal(states) # Set group as unavailable if all members are unavailable or missing self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) @@ -297,9 +264,6 @@ class FanGroup(GroupEntity, FanEntity): FanEntityFeature.SET_SPEED ) self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE) - self._attr_assumed_state |= not attribute_equal( - percentage_states, ATTR_PERCENTAGE - ) if ( percentage_states and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP) @@ -324,6 +288,3 @@ class FanGroup(GroupEntity, FanEntity): ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 ) ) - self._attr_assumed_state |= any( - state.attributes.get(ATTR_ASSUMED_STATE) for state in states - ) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index e0f7974631b..3c1ad7f0d57 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -47,11 +47,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity from .util import find_state_attributes, mean_tuple, reduce_attribute @@ -114,6 +110,19 @@ async def async_setup_entry( ) +@callback +def async_create_preview_light( + name: str, validated_config: dict[str, Any] +) -> LightGroup: + """Create a preview sensor.""" + return LightGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_ALL, False), + ) + + FORWARDED_ATTRIBUTES = frozenset( { ATTR_BRIGHTNESS, @@ -141,7 +150,7 @@ class LightGroup(GroupEntity, LightEntity): _attr_should_poll = False def __init__( - self, unique_id: str | None, name: str, entity_ids: list[str], mode: str | None + self, unique_id: str | None, name: str, entity_ids: list[str], mode: bool | None ) -> None: """Initialize a light group.""" self._entity_ids = entity_ids @@ -153,25 +162,6 @@ class LightGroup(GroupEntity, LightEntity): if mode: self.mode = all - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all lights in the light group.""" data = { diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 233d1155c53..5558eab5475 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -31,11 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity @@ -94,6 +90,16 @@ async def async_setup_entry( ) +@callback +def async_create_preview_lock(name: str, validated_config: dict[str, Any]) -> LockGroup: + """Create a preview sensor.""" + return LockGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class LockGroup(GroupEntity, LockEntity): """Representation of a lock group.""" @@ -114,25 +120,6 @@ class LockGroup(GroupEntity, LockEntity): self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_unique_id = unique_id - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - async def async_lock(self, **kwargs: Any) -> None: """Forward the lock command to all locks in the group.""" data = {ATTR_ENTITY_ID: self._entity_ids} diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index f0d076ec130..3960f400614 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,7 +1,7 @@ """Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from typing import Any @@ -44,7 +44,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -107,6 +107,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_media_player( + name: str, validated_config: dict[str, Any] +) -> MediaPlayerGroup: + """Create a preview sensor.""" + return MediaPlayerGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" @@ -139,7 +151,8 @@ class MediaPlayerGroup(MediaPlayerEntity): self.async_update_supported_features( event.data["entity_id"], event.data["new_state"] ) - self.async_update_state() + self.async_update_group_state() + self.async_write_ha_state() @callback def async_update_supported_features( @@ -208,6 +221,26 @@ class MediaPlayerGroup(MediaPlayerEntity): else: self._features[KEY_ENQUEUE].discard(entity_id) + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entities, async_state_changed_listener + ) + async def async_added_to_hass(self) -> None: """Register listeners.""" for entity_id in self._entities: @@ -216,7 +249,8 @@ class MediaPlayerGroup(MediaPlayerEntity): async_track_state_change_event( self.hass, self._entities, self.async_on_state_change ) - self.async_update_state() + self.async_update_group_state() + self.async_write_ha_state() @property def name(self) -> str: @@ -391,7 +425,7 @@ class MediaPlayerGroup(MediaPlayerEntity): await self.async_set_volume_level(max(0, volume_level - 0.1)) @callback - def async_update_state(self) -> None: + def async_update_group_state(self) -> None: """Query all members and determine the media group state.""" states = [ state.state @@ -455,4 +489,3 @@ class MediaPlayerGroup(MediaPlayerEntity): supported_features |= MediaPlayerEntityFeature.MEDIA_ENQUEUE self._attr_supported_features = supported_features - self.async_write_ha_state() diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index d62447d9947..10030ab647f 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -36,16 +36,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ( - ConfigType, - DiscoveryInfoType, - EventType, - StateType, -) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from . import GroupEntity from .const import CONF_IGNORE_NON_NUMERIC @@ -145,6 +136,23 @@ async def async_setup_entry( ) +@callback +def async_create_preview_sensor( + name: str, validated_config: dict[str, Any] +) -> SensorGroup: + """Create a preview sensor.""" + return SensorGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_IGNORE_NON_NUMERIC, False), + validated_config[CONF_TYPE], + None, + None, + None, + ) + + def calc_min( sensor_values: list[tuple[str, float, State]] ) -> tuple[dict[str, str | None], float | None]: @@ -303,25 +311,6 @@ 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: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - @callback def async_update_group_state(self) -> None: """Query all members and determine the sensor group state.""" diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 1c656b46b9e..5f3042c5bf7 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -8,6 +8,7 @@ "menu_options": { "binary_sensor": "Binary sensor group", "cover": "Cover group", + "event": "Event group", "fan": "Fan group", "light": "Light group", "lock": "Lock group", @@ -34,6 +35,14 @@ "name": "[%key:component::group::config::step::binary_sensor::data::name%]" } }, + "event": { + "title": "[%key:component::group::config::step::user::title%]", + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", + "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + } + }, "fan": { "title": "[%key:component::group::config::step::user::title%]", "data": { diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index f62c805ba1d..64bc9a99636 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -22,11 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity @@ -89,6 +85,19 @@ async def async_setup_entry( ) +@callback +def async_create_preview_switch( + name: str, validated_config: dict[str, Any] +) -> SwitchGroup: + """Create a preview sensor.""" + return SwitchGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_ALL, False), + ) + + class SwitchGroup(GroupEntity, SwitchEntity): """Representation of a switch group.""" @@ -112,25 +121,6 @@ class SwitchGroup(GroupEntity, SwitchEntity): if mode: self.mode = all - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - @callback - def async_state_changed_listener( - event: EventType[EventStateChangedData], - ) -> None: - """Handle child updates.""" - self.async_set_context(event.context) - self.async_defer_or_update_ha_state() - - self.async_on_remove( - async_track_state_change_event( - self.hass, self._entity_ids, async_state_changed_listener - ) - ) - - await super().async_added_to_hass() - async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all switches in the group.""" data = {ATTR_ENTITY_ID: self._entity_ids} diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 87822227cef..06d06ed26ce 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle, dt as dt_util diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 6f8daf2918d..87d2b55aa24 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -505,7 +505,6 @@ def setup_platform( joined_path = os.path.join(gtfs_dir, sqlite_file) gtfs = pygtfs.Schedule(joined_path) - # pylint: disable=no-member if not gtfs.feeds: pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index ec8bd818d38..d7a9fe4e836 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -23,8 +23,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index a73f0822d77..a1b11189a04 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -11,7 +11,7 @@ from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import ACTIVITY_POWER_OFF from .subscriber import HarmonySubscriberMixin diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 9227b7da617..72fb5ce5110 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -32,6 +32,7 @@ from homeassistant.core import ( HassJob, HomeAssistant, ServiceCall, + async_get_hass, callback, ) from homeassistant.exceptions import HomeAssistantError @@ -41,8 +42,8 @@ from homeassistant.helpers import ( recorder, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -149,9 +150,22 @@ SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" +def valid_addon(value: Any) -> str: + """Validate value is a valid addon slug.""" + value = cv.slug(value) + + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + + if hass and (addons := get_addons_info(hass)) is not None and value not in addons: + raise vol.Invalid("Not a valid add-on slug") + return value + + SCHEMA_NO_DATA = vol.Schema({}) -SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): cv.string}) +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} @@ -174,7 +188,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]), } ) @@ -189,7 +203,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]), } ) @@ -240,6 +254,7 @@ MAP_SERVICE_API = { } HARDWARE_INTEGRATIONS = { + "green": "homeassistant_green", "odroid-c2": "hardkernel", "odroid-c4": "hardkernel", "odroid-m1": "hardkernel", @@ -535,10 +550,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: except HassioAPIError as err: _LOGGER.warning("Can't read Supervisor data: %s", err) - async_track_point_in_utc_time( + async_call_later( hass, + HASSIO_UPDATE_INTERVAL, HassJob(update_info_data, cancel_on_shutdown=True), - utcnow() + HASSIO_UPDATE_INTERVAL, ) # Fetch data @@ -610,10 +625,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: """Set up hardaware integration for the detected board type.""" if (os_info := get_os_info(hass)) is None: # os info not yet fetched from supervisor, retry later - async_track_point_in_utc_time( + async_call_later( hass, + HASSIO_UPDATE_INTERVAL, async_setup_hardware_integration_job, - utcnow() + HASSIO_UPDATE_INTERVAL, ) return if (board := os_info.get("board")) is None: @@ -647,8 +662,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" dev_reg = dr.async_get(hass) coordinator = HassioDataUpdateCoordinator(hass, entry, dev_reg) - hass.data[ADDONS_COORDINATOR] = coordinator await coordinator.async_config_entry_first_refresh() + hass.data[ADDONS_COORDINATOR] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 0735f2645cc..5712f5d1bea 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -9,6 +9,7 @@ ATTR_ADMIN = "admin" ATTR_COMPRESSED = "compressed" ATTR_CONFIG = "config" ATTR_DATA = "data" +ATTR_SESSION_DATA_USER_ID = "user_id" ATTR_DISCOVERY = "discovery" ATTR_ENABLE = "enable" ATTR_ENDPOINT = "endpoint" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 3a6a5a9f7c3..6530aba3ea1 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -3,7 +3,8 @@ from __future__ import annotations from typing import Any -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index e4a0dd0f77e..020a4365ec6 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -8,6 +8,7 @@ import os from typing import Any import aiohttp +from yarl import URL from homeassistant.components.http import ( CONF_SERVER_HOST, @@ -530,6 +531,11 @@ class HassIO: This method is a coroutine. """ + url = f"http://{self._ip}{command}" + if url != str(URL(url)): + _LOGGER.error("Invalid request %s", command) + raise HassioAPIError() + try: request = await self.websession.request( method, diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 0e18a009323..5bcdb6896cd 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -82,7 +82,6 @@ NO_STORE = re.compile( r"|app/entrypoint.js" r")$" ) -# pylint: enable=implicit-str-concat # fmt: on RESPONSE_HEADERS_FILTER = { diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index c8fefe65e1f..8f44f7f2843 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -22,6 +22,7 @@ from .const import ( ATTR_ENDPOINT, ATTR_METHOD, ATTR_RESULT, + ATTR_SESSION_DATA_USER_ID, ATTR_TIMEOUT, ATTR_WS_EVENT, DOMAIN, @@ -40,7 +41,6 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema( ) # Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` -# pylint: disable=implicit-str-concat # fmt: off WS_NO_ADMIN_ENDPOINTS = re.compile( r"^(?:" @@ -49,7 +49,6 @@ WS_NO_ADMIN_ENDPOINTS = re.compile( r")$" # noqa: ISC001 ) # fmt: on -# pylint: enable=implicit-str-concat _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -115,12 +114,21 @@ async def websocket_supervisor_api( ): raise Unauthorized() supervisor: HassIO = hass.data[DOMAIN] + + command = msg[ATTR_ENDPOINT] + payload = msg.get(ATTR_DATA, {}) + + if command == "/ingress/session": + # Send user ID on session creation, so the supervisor can correlate session tokens with users + # for every request that is authenticated with the given ingress session token. + payload[ATTR_SESSION_DATA_USER_ID] = connection.user.id + try: result = await supervisor.send_command( - msg[ATTR_ENDPOINT], + command, method=msg[ATTR_METHOD], timeout=msg.get(ATTR_TIMEOUT, 10), - payload=msg.get(ATTR_DATA, {}), + payload=payload, source="core.websocket_api", ) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index c111a23bf06..e2487e90a99 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -23,11 +23,11 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 537f782ad09..193a86a3d37 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -22,8 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.start import async_at_started from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 76d75e51725..ba060caa43a 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -17,12 +17,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 95304371e79..99de8b99675 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -66,19 +66,6 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( - "boost_heating", - { - vol.Required(ATTR_TIME_PERIOD): vol.All( - cv.time_period, - cv.positive_timedelta, - lambda td: td.total_seconds() // 60, - ), - vol.Optional(ATTR_TEMPERATURE, default="25.0"): vol.Coerce(float), - }, - "async_heating_boost", - ) - platform.async_register_entity_service( SERVICE_BOOST_HEATING_ON, { @@ -137,14 +124,6 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): temperature = curtemp + 0.5 await self.hive.heating.setBoostOn(self.device, 30, temperature) - async def async_heating_boost(self, time_period, temperature): - """Handle boost heating service call.""" - _LOGGER.warning( - "Hive Service heating_boost will be removed in 2021.7.0, please update to" - " heating_boost_on" - ) - await self.async_heating_boost_on(time_period, temperature) - @refresh_system async def async_heating_boost_on(self, time_period, temperature): """Handle boost heating service call.""" diff --git a/homeassistant/components/hive/services.yaml b/homeassistant/components/hive/services.yaml index 96066246230..e2ad59852a3 100644 --- a/homeassistant/components/hive/services.yaml +++ b/homeassistant/components/hive/services.yaml @@ -1,22 +1,3 @@ -boost_heating: - target: - entity: - integration: hive - domain: climate - fields: - time_period: - required: true - example: 01:30:00 - selector: - time: - temperature: - default: 25.0 - selector: - number: - min: 7 - max: 35 - step: 0.5 - unit_of_measurement: ° boost_heating_on: target: entity: diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index e2a3e9dc7e1..277a1aac754 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -58,20 +58,6 @@ } }, "services": { - "boost_heating": { - "name": "Boost heating (to be deprecated)", - "description": "To be deprecated please use boost_heating_on.", - "fields": { - "time_period": { - "name": "Time period", - "description": "Set the time period for the boost." - }, - "temperature": { - "name": "Temperature", - "description": "Set the target temperature for the boost period." - } - } - }, "boost_heating_on": { "name": "Boost heating on", "description": "Sets the boost mode ON defining the period of time and the desired target temperature for the boost.", @@ -82,7 +68,7 @@ }, "temperature": { "name": "Temperature", - "description": "[%key:component::hive::services::boost_heating::fields::temperature::description%]" + "description": "Set the target temperature for the boost period." } } }, diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 4920e1542d5..01f695ad1a6 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -1,7 +1,6 @@ """Config flow for HLK-SW16.""" import asyncio -import async_timeout from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol @@ -36,7 +35,7 @@ async def connect_client(hass, user_input): reconnect_interval=DEFAULT_RECONNECT_INTERVAL, keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, ) - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): return await client_aw diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 0fa14682f44..7377c4b60d0 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -19,12 +19,13 @@ from homeassistant.const import ( CONF_DEVICE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, 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 @@ -133,6 +134,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "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.""" diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 3c2ac52929a..12fe7be3be9 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -3,8 +3,9 @@ import logging from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .api import HomeConnectDevice from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index 007f8895bf0..2ed37480705 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -1,8 +1,8 @@ """The Legrand Home+ Control integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from homepluscontrol.homeplusapi import HomePlusControlApiError import voluptuous as vol @@ -15,6 +15,11 @@ from homeassistant.helpers import ( 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 @@ -49,6 +54,8 @@ 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.""" @@ -57,6 +64,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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, @@ -70,6 +91,20 @@ 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( @@ -100,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await api.async_get_modules() except HomePlusControlApiError as err: raise UpdateFailed( @@ -168,4 +203,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # 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 diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index 9e860b397fb..280a92055bd 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -16,5 +16,11 @@ "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 index 99766ebfec9..ef2c1447bf4 100644 --- a/homeassistant/components/home_plus_control/switch.py +++ b/homeassistant/components/home_plus_control/switch.py @@ -6,7 +6,7 @@ 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.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index d0e74d5b04e..a4266a70add 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -1,6 +1,7 @@ """Offer event listening automation rules.""" from __future__ import annotations +from collections.abc import ItemsView from typing import Any import voluptuous as vol @@ -47,9 +48,8 @@ async def async_attach_trigger( event_types = template.render_complex( config[CONF_EVENT_TYPE], variables, limited=True ) - removes = [] - - event_data_schema = None + event_data_schema: vol.Schema | None = None + event_data_items: ItemsView | None = None if CONF_EVENT_DATA in config: # Render the schema input template.attach(hass, config[CONF_EVENT_DATA]) @@ -57,13 +57,21 @@ async def async_attach_trigger( event_data.update( template.render_complex(config[CONF_EVENT_DATA], variables, limited=True) ) - # Build the schema - event_data_schema = vol.Schema( - {vol.Required(key): value for key, value in event_data.items()}, - extra=vol.ALLOW_EXTRA, - ) + # Build the schema or a an items view if the schema is simple + # and does not contain sub-dicts. We explicitly do not check for + # list like the context data below since lists are a special case + # only for context data. (see test test_event_data_with_list) + if any(isinstance(value, dict) for value in event_data.values()): + event_data_schema = vol.Schema( + {vol.Required(key): value for key, value in event_data.items()}, + extra=vol.ALLOW_EXTRA, + ) + else: + # Use a simple items comparison if possible + event_data_items = event_data.items() - event_context_schema = None + event_context_schema: vol.Schema | None = None + event_context_items: ItemsView | None = None if CONF_EVENT_CONTEXT in config: # Render the schema input template.attach(hass, config[CONF_EVENT_CONTEXT]) @@ -71,14 +79,23 @@ async def async_attach_trigger( event_context.update( template.render_complex(config[CONF_EVENT_CONTEXT], variables, limited=True) ) - # Build the schema - event_context_schema = vol.Schema( - { - vol.Required(key): _schema_value(value) - for key, value in event_context.items() - }, - extra=vol.ALLOW_EXTRA, - ) + # Build the schema or a an items view if the schema is simple + # and does not contain lists. Lists are a special case to support + # matching events by user_id. (see test test_if_fires_on_multiple_user_ids) + # This can likely be optimized further in the future to handle the + # multiple user_id case without requiring expensive schema + # validation. + if any(isinstance(value, list) for value in event_context.values()): + event_context_schema = vol.Schema( + { + vol.Required(key): _schema_value(value) + for key, value in event_context.items() + }, + extra=vol.ALLOW_EXTRA, + ) + else: + # Use a simple items comparison if possible + event_context_items = event_context.items() job = HassJob(action, f"event trigger {trigger_info}") @@ -88,9 +105,20 @@ async def async_attach_trigger( try: # Check that the event data and context match the configured # schema if one was provided - if event_data_schema: + if event_data_items: + # Fast path for simple items comparison + if not (event.data.items() >= event_data_items): + return False + elif event_data_schema: + # Slow path for schema validation event_data_schema(event.data) - if event_context_schema: + + if event_context_items: + # Fast path for simple items comparison + 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())) except vol.Invalid: # If event doesn't match, skip event diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index eec66a560a5..2cac07e7cd9 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -8,13 +8,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL -from homeassistant.core import ( - CALLBACK_TYPE, - HassJob, - HomeAssistant, - State, - callback, -) +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers import ( config_validation as cv, entity_registry as er, diff --git a/homeassistant/components/homeassistant_green/__init__.py b/homeassistant/components/homeassistant_green/__init__.py new file mode 100644 index 00000000000..fbcd2093778 --- /dev/null +++ b/homeassistant/components/homeassistant_green/__init__.py @@ -0,0 +1,27 @@ +"""The Home Assistant Green integration.""" +from __future__ import annotations + +from homeassistant.components.hassio import get_os_info +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Home Assistant Green config entry.""" + if (os_info := get_os_info(hass)) is None: + # The hassio integration has not yet fetched data from the supervisor + raise ConfigEntryNotReady + + board: str | None + if (board := os_info.get("board")) is None or board != "green": + # Not running on a Home Assistant Green, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return True diff --git a/homeassistant/components/homeassistant_green/config_flow.py b/homeassistant/components/homeassistant_green/config_flow.py new file mode 100644 index 00000000000..17ba9aacbc5 --- /dev/null +++ b/homeassistant/components/homeassistant_green/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for the Home Assistant Green integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class HomeAssistantGreenConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Green.""" + + VERSION = 1 + + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="Home Assistant Green", data={}) diff --git a/homeassistant/components/homeassistant_green/const.py b/homeassistant/components/homeassistant_green/const.py new file mode 100644 index 00000000000..9046a44c12b --- /dev/null +++ b/homeassistant/components/homeassistant_green/const.py @@ -0,0 +1,3 @@ +"""Constants for the Home Assistant Green integration.""" + +DOMAIN = "homeassistant_green" diff --git a/homeassistant/components/homeassistant_green/hardware.py b/homeassistant/components/homeassistant_green/hardware.py new file mode 100644 index 00000000000..2b5268f8d03 --- /dev/null +++ b/homeassistant/components/homeassistant_green/hardware.py @@ -0,0 +1,44 @@ +"""The Home Assistant Green hardware platform.""" +from __future__ import annotations + +from homeassistant.components.hardware.models import BoardInfo, HardwareInfo +from homeassistant.components.hassio import get_os_info +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +BOARD_NAME = "Home Assistant Green" +MANUFACTURER = "homeassistant" +MODEL = "green" + + +@callback +def async_info(hass: HomeAssistant) -> list[HardwareInfo]: + """Return board info.""" + if (os_info := get_os_info(hass)) is None: + raise HomeAssistantError + board: str | None + if (board := os_info.get("board")) is None: + raise HomeAssistantError + if not board == "green": + raise HomeAssistantError + + config_entries = [ + entry.entry_id for entry in hass.config_entries.async_entries(DOMAIN) + ] + + return [ + HardwareInfo( + board=BoardInfo( + hassio_board_id=board, + manufacturer=MANUFACTURER, + model=MODEL, + revision=None, + ), + config_entries=config_entries, + dongle=None, + name=BOARD_NAME, + url=None, + ) + ] diff --git a/homeassistant/components/homeassistant_green/manifest.json b/homeassistant/components/homeassistant_green/manifest.json new file mode 100644 index 00000000000..7c9dd0322ec --- /dev/null +++ b/homeassistant/components/homeassistant_green/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "homeassistant_green", + "name": "Home Assistant Green", + "codeowners": ["@home-assistant/core"], + "config_flow": false, + "dependencies": ["hardware", "hassio", "homeassistant_hardware"], + "documentation": "https://www.home-assistant.io/integrations/homeassistant_green", + "integration_type": "hardware" +} diff --git a/homeassistant/components/homeassistant_hardware/const.py b/homeassistant/components/homeassistant_hardware/const.py index dd3a254d097..e4aa7c80f8d 100644 --- a/homeassistant/components/homeassistant_hardware/const.py +++ b/homeassistant/components/homeassistant_hardware/const.py @@ -5,3 +5,4 @@ import logging LOGGER = logging.getLogger(__package__) SILABS_MULTIPROTOCOL_ADDON_SLUG = "core_silabs_multiprotocol" +SILABS_FLASHER_ADDON_SLUG = "core_silabs_flasher" diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index e4d9902346c..b4723a88742 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -3,10 +3,12 @@ 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 +import async_timeout import voluptuous as vol import yarl @@ -33,17 +35,19 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store -from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG +from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG _LOGGER = logging.getLogger(__name__) -DATA_ADDON_MANAGER = "silabs_multiprotocol_addon_manager" +DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager" +DATA_FLASHER_ADDON_MANAGER = "silabs_flasher" -ADDON_SETUP_TIMEOUT = 5 -ADDON_SETUP_TIMEOUT_ROUNDS = 40 +ADDON_STATE_POLL_INTERVAL = 3 +ADDON_INFO_POLL_TIMEOUT = 15 * 60 CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware" CONF_ADDON_DEVICE = "device" +CONF_DISABLE_MULTI_PAN = "disable_multi_pan" CONF_ENABLE_MULTI_PAN = "enable_multi_pan" DEFAULT_CHANNEL = 15 @@ -55,15 +59,64 @@ STORAGE_VERSION_MINOR = 1 SAVE_DELAY = 10 -@singleton(DATA_ADDON_MANAGER) -async def get_addon_manager(hass: HomeAssistant) -> MultiprotocolAddonManager: +@singleton(DATA_MULTIPROTOCOL_ADDON_MANAGER) +async def get_multiprotocol_addon_manager( + hass: HomeAssistant, +) -> MultiprotocolAddonManager: """Get the add-on manager.""" manager = MultiprotocolAddonManager(hass) await manager.async_setup() return manager -class MultiprotocolAddonManager(AddonManager): +class WaitingAddonManager(AddonManager): + """Addon manager which supports waiting operations for managing an addon.""" + + async def async_wait_until_addon_state(self, *states: AddonState) -> None: + """Poll an addon's info until it is in a specific state.""" + async with async_timeout.timeout(ADDON_INFO_POLL_TIMEOUT): + while True: + try: + info = await self.async_get_addon_info() + except AddonError: + info = None + + _LOGGER.debug("Waiting for addon to be in state %s: %s", states, info) + + if info is not None and info.state in states: + break + + await asyncio.sleep(ADDON_STATE_POLL_INTERVAL) + + async def async_start_addon_waiting(self) -> None: + """Start an add-on.""" + await self.async_schedule_start_addon() + await self.async_wait_until_addon_state(AddonState.RUNNING) + + async def async_install_addon_waiting(self) -> None: + """Install an add-on.""" + await self.async_schedule_install_addon() + await self.async_wait_until_addon_state( + AddonState.RUNNING, + AddonState.NOT_RUNNING, + ) + + async def async_uninstall_addon_waiting(self) -> None: + """Uninstall an add-on.""" + try: + info = await self.async_get_addon_info() + except AddonError: + info = None + + # Do not try to uninstall an addon if it is already uninstalled + if info is not None and info.state == AddonState.NOT_INSTALLED: + return + + await self.async_uninstall_addon() + await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED) + + +class MultiprotocolAddonManager(WaitingAddonManager): """Silicon Labs Multiprotocol add-on manager.""" def __init__(self, hass: HomeAssistant) -> None: @@ -207,6 +260,18 @@ class MultipanProtocol(Protocol): """ +@singleton(DATA_FLASHER_ADDON_MANAGER) +@callback +def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager: + """Get the flasher add-on manager.""" + return WaitingAddonManager( + hass, + LOGGER, + "Silicon Labs Flasher", + SILABS_FLASHER_ADDON_SLUG, + ) + + @dataclasses.dataclass class SerialPortSettings: """Serial port settings.""" @@ -242,9 +307,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): ZhaMultiPANMigrationHelper, ) - # If we install the add-on we should uninstall it on entry remove. self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None + self.stop_task: asyncio.Task | None = None self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None self.config_entry = config_entry self.original_addon_config: dict[str, Any] | None = None @@ -275,37 +340,37 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): """Return the correct flow manager.""" return self.hass.config_entries.options - async def _async_get_addon_info(self) -> AddonInfo: + 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.""" - addon_manager: AddonManager = await get_addon_manager(self.hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: _LOGGER.error(err) - raise AbortFlow("addon_info_failed") from err + raise AbortFlow( + "addon_info_failed", + description_placeholders={"addon_name": addon_manager.addon_name}, + ) from err return addon_info - async def _async_set_addon_config(self, config: dict) -> None: + async def _async_set_addon_config( + self, config: dict, addon_manager: AddonManager + ) -> None: """Set Silicon Labs Multiprotocol add-on config.""" - addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_set_addon_options(config) except AddonError as err: _LOGGER.error(err) raise AbortFlow("addon_set_config_failed") from err - async def _async_install_addon(self) -> None: - """Install the Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = await 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) - ) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -319,7 +384,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle logic when on Supervisor host.""" - addon_info = await self._async_get_addon_info() + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(multipan_manager) if addon_info.state == AddonState.NOT_INSTALLED: return await self.async_step_addon_not_installed() @@ -347,19 +413,26 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): ) -> FlowResult: """Install Silicon Labs Multiprotocol add-on.""" if not self.install_task: - self.install_task = self.hass.async_create_task(self._async_install_addon()) + 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() + ), + "SiLabs Multiprotocol addon install", + ) return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" + step_id="install_addon", + progress_action="install_addon", + description_placeholders={"addon_name": multipan_manager.addon_name}, ) 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") - - self.install_task = None + finally: + self.install_task = None return self.async_show_progress_done(next_step_id="configure_addon") @@ -367,7 +440,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Add-on installation failed.""" - return self.async_abort(reason="addon_install_failed") + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + return self.async_abort( + reason="addon_install_failed", + description_placeholders={"addon_name": multipan_manager.addon_name}, + ) async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None @@ -386,7 +463,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async_get_channel as async_get_zha_channel, ) - addon_info = await self._async_get_addon_info() + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(multipan_manager) addon_config = addon_info.options @@ -426,14 +504,14 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): multipan_channel = zha_channel # Initialize the shared channel - multipan_manager = await get_addon_manager(self.hass) + multipan_manager = await get_multiprotocol_addon_manager(self.hass) multipan_manager.async_set_channel(multipan_channel) if new_addon_config != addon_config: # Copy the add-on config to keep the objects separate. self.original_addon_config = dict(addon_config) _LOGGER.debug("Reconfiguring addon with %s", new_addon_config) - await self._async_set_addon_config(new_addon_config) + await self._async_set_addon_config(new_addon_config, multipan_manager) return await self.async_step_start_addon() @@ -442,9 +520,16 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): ) -> FlowResult: """Start Silicon Labs Multiprotocol add-on.""" if not self.start_task: - self.start_task = self.hass.async_create_task(self._async_start_addon()) + 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() + ) + ) return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" + step_id="start_addon", + progress_action="start_addon", + description_placeholders={"addon_name": multipan_manager.addon_name}, ) try: @@ -461,18 +546,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Add-on start failed.""" - return self.async_abort(reason="addon_start_failed") - - async def _async_start_addon(self) -> None: - """Start Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = await get_addon_manager(self.hass) - try: - await addon_manager.async_schedule_start_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) - ) + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + return self.async_abort( + reason="addon_start_failed", + description_placeholders={"addon_name": multipan_manager.addon_name}, + ) async def async_step_finish_addon_setup( self, user_input: dict[str, Any] | None = None @@ -493,16 +571,25 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): return self.async_create_entry(title="", data={}) + async def async_step_addon_installed_other_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show dialog explaining the addon is in use by another device.""" + if user_input is None: + return self.async_show_form(step_id="addon_installed_other_device") + return self.async_create_entry(title="", data={}) + async def async_step_addon_installed( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle logic when the addon is already installed.""" - addon_info = await self._async_get_addon_info() + multipan_manager = await get_multiprotocol_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(multipan_manager) serial_device = (await self._async_serial_port_settings()).device - if addon_info.options.get(CONF_ADDON_DEVICE) == serial_device: - return await self.async_step_show_addon_menu() - return await self.async_step_addon_installed_other_device() + if addon_info.options.get(CONF_ADDON_DEVICE) != serial_device: + return await self.async_step_addon_installed_other_device() + return await self.async_step_show_addon_menu() async def async_step_show_addon_menu( self, user_input: dict[str, Any] | None = None @@ -520,7 +607,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Reconfigure the addon.""" - multipan_manager = await get_addon_manager(self.hass) + multipan_manager = await get_multiprotocol_addon_manager(self.hass) active_platforms = await multipan_manager.async_active_platforms() if set(active_platforms) != {"otbr", "zha"}: return await self.async_step_notify_unknown_multipan_user() @@ -540,7 +627,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Change the channel.""" - multipan_manager = await get_addon_manager(self.hass) + multipan_manager = await get_multiprotocol_addon_manager(self.hass) if user_input is None: channels = [str(x) for x in range(11, 27)] suggested_channel = DEFAULT_CHANNEL @@ -584,23 +671,217 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def async_step_uninstall_addon( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Uninstall the addon (not implemented).""" - return await self.async_step_show_revert_guide() + """Uninstall the addon and revert the firmware.""" + if user_input is None: + return self.async_show_form( + step_id="uninstall_addon", + data_schema=vol.Schema( + {vol.Required(CONF_DISABLE_MULTI_PAN, default=False): bool} + ), + description_placeholders={"hardware_name": self._hardware_name()}, + ) + if not user_input[CONF_DISABLE_MULTI_PAN]: + return self.async_create_entry(title="", data={}) - async def async_step_show_revert_guide( + return await self.async_step_firmware_revert() + + async def async_step_firmware_revert( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Link to a guide for reverting to Zigbee firmware.""" - if user_input is None: - return self.async_show_form(step_id="show_revert_guide") - return self.async_create_entry(title="", data={}) + """Install the flasher addon, if necessary.""" - async def async_step_addon_installed_other_device( + flasher_manager = get_flasher_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(flasher_manager) + + if addon_info.state == AddonState.NOT_INSTALLED: + return await self.async_step_install_flasher_addon() + + if addon_info.state == AddonState.NOT_RUNNING: + return await self.async_step_configure_flasher_addon() + + # If the addon is already installed and running, fail + return self.async_abort( + reason="addon_already_running", + description_placeholders={"addon_name": flasher_manager.addon_name}, + ) + + async def async_step_install_flasher_addon( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Show dialog explaining the addon is in use by another device.""" - if user_input is None: - return self.async_show_form(step_id="addon_installed_other_device") + """Show progress dialog for installing flasher addon.""" + flasher_manager = get_flasher_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(flasher_manager) + + _LOGGER.debug("Flasher addon state: %s", addon_info) + + if not self.install_task: + self.install_task = self.hass.async_create_task( + self._resume_flow_when_done( + flasher_manager.async_install_addon_waiting() + ), + "SiLabs Flasher addon install", + ) + return self.async_show_progress( + step_id="install_flasher_addon", + progress_action="install_addon", + description_placeholders={"addon_name": flasher_manager.addon_name}, + ) + + try: + await self.install_task + except AddonError as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="install_failed") + finally: + self.install_task = None + + return self.async_show_progress_done(next_step_id="configure_flasher_addon") + + async def async_step_configure_flasher_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform initial backup and reconfigure ZHA.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) + + zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN) + new_settings = await self._async_serial_port_settings() + + _LOGGER.debug("Using new ZHA settings: %s", new_settings) + + if zha_entries: + zha_migration_mgr = ZhaMultiPANMigrationHelper(self.hass, zha_entries[0]) + migration_data = { + "new_discovery_info": { + "name": self._hardware_name(), + "port": { + "path": new_settings.device, + "baudrate": int(new_settings.baudrate), + "flow_control": ( + "hardware" if new_settings.flow_control else None + ), + }, + "radio_type": "ezsp", + }, + "old_discovery_info": { + "hw": { + "name": self._zha_name(), + "port": {"path": get_zigbee_socket()}, + "radio_type": "ezsp", + } + }, + } + _LOGGER.debug("Starting ZHA migration with: %s", migration_data) + try: + if await zha_migration_mgr.async_initiate_migration(migration_data): + self._zha_migration_mgr = zha_migration_mgr + except Exception as err: + _LOGGER.exception("Unexpected exception during ZHA migration") + raise AbortFlow("zha_migration_failed") from err + + flasher_manager = get_flasher_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(flasher_manager) + new_addon_config = { + **addon_info.options, + "device": new_settings.device, + "flow_control": new_settings.flow_control, + } + + _LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config) + await self._async_set_addon_config(new_addon_config, flasher_manager) + + return await self.async_step_uninstall_multiprotocol_addon() + + async def async_step_uninstall_multiprotocol_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Uninstall Silicon Labs Multiprotocol add-on.""" + + 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() + ), + "SiLabs Multiprotocol addon uninstall", + ) + return self.async_show_progress( + step_id="uninstall_multiprotocol_addon", + progress_action="uninstall_multiprotocol_addon", + description_placeholders={"addon_name": multipan_manager.addon_name}, + ) + + try: + await self.stop_task + finally: + self.stop_task = None + + return self.async_show_progress_done(next_step_id="start_flasher_addon") + + async def async_step_start_flasher_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start Silicon Labs Flasher add-on.""" + + 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() + # Now that the addon is running, wait for it to finish + await flasher_manager.async_wait_until_addon_state( + AddonState.NOT_RUNNING + ) + + self.start_task = self.hass.async_create_task( + self._resume_flow_when_done(start_and_wait_until_done()) + ) + return self.async_show_progress( + step_id="start_flasher_addon", + progress_action="start_flasher_addon", + description_placeholders={"addon_name": flasher_manager.addon_name}, + ) + + try: + await self.start_task + except (AddonError, AbortFlow) as err: + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="flasher_failed") + finally: + self.start_task = None + + return self.async_show_progress_done(next_step_id="flashing_complete") + + async def async_step_flasher_failed( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Flasher add-on start failed.""" + flasher_manager = get_flasher_addon_manager(self.hass) + return self.async_abort( + reason="addon_start_failed", + description_placeholders={"addon_name": flasher_manager.addon_name}, + ) + + async def async_step_flashing_complete( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Finish flashing and update the config entry.""" + flasher_manager = get_flasher_addon_manager(self.hass) + await flasher_manager.async_uninstall_addon_waiting() + + # Finish ZHA migration if needed + if self._zha_migration_mgr: + try: + await self._zha_migration_mgr.async_finish_migration() + except Exception as err: + _LOGGER.exception("Unexpected exception during ZHA migration") + raise AbortFlow("zha_migration_failed") from err + return self.async_create_entry(title="", data={}) @@ -613,18 +894,18 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: if not is_hassio(hass): return - addon_manager: AddonManager = await get_addon_manager(hass) + multipan_manager = await get_multiprotocol_addon_manager(hass) try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() + addon_info: AddonInfo = await multipan_manager.async_get_addon_info() except AddonError as err: _LOGGER.error(err) raise HomeAssistantError from err # Request the addon to start if it's not started - # addon_manager.async_start_addon returns as soon as the start request has been sent + # `async_start_addon` returns as soon as the start request has been sent # and does not wait for the addon to be started, so we raise below if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() + await multipan_manager.async_start_addon() if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): _LOGGER.debug("Multi pan addon installed and in state %s", addon_info.state) @@ -640,8 +921,8 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> if not is_hassio(hass): return False - addon_manager: AddonManager = await get_addon_manager(hass) - addon_info: AddonInfo = await addon_manager.async_get_addon_info() + multipan_manager = await get_multiprotocol_addon_manager(hass) + addon_info: AddonInfo = await multipan_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: return False diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 45e85f5a474..a66e4879f68 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -23,7 +23,7 @@ "data": { "channel": "Channel" }, - "description": "Start a channel change for your Zigbee and Thread networks.\n\nNote: this is an advanced operation and can leave your Thread and Zigbee networks inoperable if the new channel is congested. Depending on existing network conditions, many of your devices may not migrate to the new channel and will require re-joining before they start working again. Use with caution.\n\nOnce you selected **Submit**, the channel change starts quietly in the background and will finish after a few minutes. " + "description": "Start a channel change for your Zigbee and Thread networks.\n\nNote: this is an advanced operation and can leave your Thread and Zigbee networks inoperable if the new channel is congested. Depending on existing network conditions, many of your devices may not migrate to the new channel and will require re-joining before they start working again. Use with caution.\n\nOnce you have selected **Submit**, the channel change starts quietly in the background and will finish after a few minutes." }, "install_addon": { "title": "The Silicon Labs Multiprotocol add-on installation has started" @@ -39,31 +39,42 @@ "reconfigure_addon": { "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support" }, - "show_revert_guide": { - "title": "Multiprotocol support is enabled for this device", - "description": "If you want to change to Zigbee only firmware, please complete the following manual steps:\n\n * Remove the Silicon Labs Multiprotocol addon\n\n * Flash the Zigbee only firmware, follow the guide at https://github.com/NabuCasa/silabs-firmware/wiki/Flash-Silicon-Labs-radio-firmware-manually.\n\n * Reconfigure ZHA to migrate settings to the reflashed radio" - }, "start_addon": { "title": "The Silicon Labs Multiprotocol add-on is starting." }, "uninstall_addon": { - "title": "Remove IEEE 802.15.4 radio multiprotocol support." + "title": "Remove IEEE 802.15.4 radio multiprotocol support", + "description": "Disabling multiprotocol support will revert your {hardware_name}'s radio back to Zigbee-only firmware and will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restoring a backup.", + "data": { + "disable_multi_pan": "Disable multiprotocol support" + } + }, + "install_flasher_addon": { + "title": "The Silicon Labs Flasher add-on installation has started" + }, + "configure_flasher_addon": { + "title": "The Silicon Labs Flasher add-on installation has started" + }, + "start_flasher_addon": { + "title": "Installing firmware", + "description": "Zigbee firmware is now being installed. This will take a few minutes." } }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "addon_info_failed": "Failed to get Silicon Labs Multiprotocol add-on info.", - "addon_install_failed": "Failed to install the Silicon Labs Multiprotocol add-on.", - "addon_set_config_failed": "Failed to set Silicon Labs Multiprotocol configuration.", - "addon_start_failed": "Failed to start the Silicon Labs Multiprotocol add-on.", + "addon_info_failed": "Failed to get {addon_name} add-on info.", + "addon_install_failed": "Failed to install the {addon_name} add-on.", + "addon_already_running": "Failed to start the {addon_name} add-on because it is already running.", + "addon_set_config_failed": "Failed to set {addon_name} configuration.", + "addon_start_failed": "Failed to start the {addon_name} add-on.", "not_hassio": "The hardware options can only be configured on HassOS installations.", "zha_migration_failed": "The ZHA migration did not succeed." }, "progress": { - "install_addon": "Please wait while the Silicon Labs Multiprotocol add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Silicon Labs Multiprotocol add-on start completes. This may take some seconds." + "install_addon": "Please wait while the {addon_name} add-on installation finishes. This can take several minutes.", + "start_addon": "Please wait while the {addon_name} add-on start completes. This may take some seconds." } } } diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py index 217a6e57543..bd752278397 100644 --- a/homeassistant/components/homeassistant_sky_connect/hardware.py +++ b/homeassistant/components/homeassistant_sky_connect/hardware.py @@ -6,6 +6,7 @@ from homeassistant.core import HomeAssistant, callback from .const import DOMAIN +DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/" DONGLE_NAME = "Home Assistant SkyConnect" @@ -26,7 +27,7 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: description=entry.data["description"], ), name=DONGLE_NAME, - url=None, + url=DOCUMENTATION_URL, ) for entry in entries ] diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 9bc1a49125b..58fc0180743 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -38,15 +38,25 @@ "reconfigure_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" }, - "show_revert_guide": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", - "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" - }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" }, "uninstall_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::description%]", + "data": { + "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" + } + }, + "install_flasher_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]" + }, + "configure_flasher_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]" + }, + "start_flasher_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]" } }, "error": { @@ -55,6 +65,7 @@ "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]", + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 3da67023abd..8be7b8a4ff7 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -1,11 +1,11 @@ """Config flow for the Home Assistant Yellow integration.""" from __future__ import annotations +import asyncio import logging from typing import Any import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.hassio import ( @@ -80,7 +80,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl if self._hw_settings == user_input: return self.async_create_entry(data={}) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await async_set_yellow_settings(self.hass, user_input) except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: _LOGGER.warning("Failed to write hardware settings", exc_info=err) @@ -88,7 +88,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl return await self.async_step_confirm_reboot() try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self._hw_settings: dict[str, bool] = await async_get_yellow_settings( self.hass ) diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py index b67eb50ff2c..0749ca8edc6 100644 --- a/homeassistant/components/homeassistant_yellow/hardware.py +++ b/homeassistant/components/homeassistant_yellow/hardware.py @@ -9,6 +9,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAME = "Home Assistant Yellow" +DOCUMENTATION_URL = "https://yellow.home-assistant.io/documentation/" MANUFACTURER = "homeassistant" MODEL = "yellow" @@ -39,6 +40,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: config_entries=config_entries, dongle=None, name=BOARD_NAME, - url=None, + url=DOCUMENTATION_URL, ) ] diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 644a3c04553..e5250f163ce 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -60,15 +60,25 @@ "reconfigure_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" }, - "show_revert_guide": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", - "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" - }, "start_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_addon::title%]" }, "uninstall_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::description%]", + "data": { + "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" + } + }, + "install_flasher_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]" + }, + "configure_flasher_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]" + }, + "start_flasher_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]" } }, "error": { @@ -77,6 +87,7 @@ "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", "addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]", + "addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]", "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 04ba4cc1a6a..6f3067d7a78 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.7.1", - "fnv-hash-fast==0.4.0", + "fnv-hash-fast==0.4.1", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index df43d8929e9..d3e9a0f13a6 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -23,6 +23,10 @@ from homeassistant.components.climate import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, FAN_ON, SWING_OFF, SWING_VERTICAL, @@ -35,6 +39,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import KNOWN_DEVICES from .connection import HKDevice @@ -86,6 +94,16 @@ SWING_MODE_HASS_TO_HOMEKIT = {v: k for k, v in SWING_MODE_HOMEKIT_TO_HASS.items( DEFAULT_MIN_STEP: Final = 1.0 +ROTATION_SPEED_LOW = 33 +ROTATION_SPEED_MEDIUM = 66 +ROTATION_SPEED_HIGH = 100 + +HASS_FAN_MODE_TO_HOMEKIT_ROTATION = { + FAN_LOW: ROTATION_SPEED_LOW, + FAN_MEDIUM: ROTATION_SPEED_MEDIUM, + FAN_HIGH: ROTATION_SPEED_HIGH, +} + async def async_setup_entry( hass: HomeAssistant, @@ -170,8 +188,45 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD, CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD, CharacteristicsTypes.SWING_MODE, + CharacteristicsTypes.ROTATION_SPEED, ] + def _get_rotation_speed_range(self) -> tuple[float, float]: + rotation_speed = self.service[CharacteristicsTypes.ROTATION_SPEED] + return round(rotation_speed.minValue or 0) + 1, round( + rotation_speed.maxValue or 100 + ) + + @property + def fan_modes(self) -> list[str]: + """Return the available fan modes.""" + return [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + speed_range = self._get_rotation_speed_range() + speed_percentage = ranged_value_to_percentage( + speed_range, self.service.value(CharacteristicsTypes.ROTATION_SPEED) + ) + # homekit value 0 33 66 100 + if speed_percentage > ROTATION_SPEED_MEDIUM: + return FAN_HIGH + if speed_percentage > ROTATION_SPEED_LOW: + return FAN_MEDIUM + if speed_percentage > 0: + return FAN_LOW + return FAN_OFF + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + rotation = HASS_FAN_MODE_TO_HOMEKIT_ROTATION.get(fan_mode, 0) + speed_range = self._get_rotation_speed_range() + speed = round(percentage_to_ranged_value(speed_range, rotation)) + await self.async_put_characteristics( + {CharacteristicsTypes.ROTATION_SPEED: speed} + ) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) @@ -387,6 +442,9 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): if self.service.has(CharacteristicsTypes.SWING_MODE): features |= ClimateEntityFeature.SWING_MODE + if self.service.has(CharacteristicsTypes.ROTATION_SPEED): + features |= ClimateEntityFeature.FAN_MODE + return features diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 4ba22317644..3e5fd4655d6 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -26,7 +26,7 @@ from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, c from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_track_time_interval from .config_flow import normalize_hkid diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 6ebe777d5f8..6fdb450a5b4 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -12,7 +12,8 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .connection import HKDevice, valid_serial_number diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 5096544ba05..9567ff83cea 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==2.6.16"], + "requirements": ["aiohomekit==3.0.2"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index d7230de0832..5803b8aa839 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -466,21 +466,23 @@ class HomeKitBatterySensor(HomeKitSensor): @property def icon(self) -> str: """Return the sensor icon.""" - if not self.available or self.state is None: + native_value = self.native_value + if not self.available or native_value is None: return "mdi:battery-unknown" # This is similar to the logic in helpers.icon, but we have delegated the # decision about what mdi:battery-alert is to the device. icon = "mdi:battery" - if self.is_charging and self.state > 10: - percentage = int(round(self.state / 20 - 0.01)) * 20 + is_charging = self.is_charging + if is_charging and native_value > 10: + percentage = int(round(native_value / 20 - 0.01)) * 20 icon += f"-charging-{percentage}" - elif self.is_charging: + elif is_charging: icon += "-outline" elif self.is_low_battery: icon += "-alert" - elif self.state < 95: - percentage = max(int(round(self.state / 10 - 0.01)) * 10, 10) + elif native_value < 95: + percentage = max(int(round(native_value / 10 - 0.01)) * 10, 10) icon += f"-{percentage}" return icon diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 1b29fcb1068..1a2f2293c1c 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN as HMIPC_DOMAIN diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index fb4bfdd637e..6730f722685 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -35,7 +35,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 7aa68709040..09d00e9bee1 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity @@ -180,7 +180,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ) or self._has_switch: if not profile_names: presets.append(PRESET_NONE) - presets.append(PRESET_BOOST) + presets.extend([PRESET_BOOST, PRESET_ECO]) presets.extend(profile_names) @@ -223,6 +223,8 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): await self._device.set_boost(False) if preset_mode == PRESET_BOOST: await self._device.set_boost() + if preset_mode == PRESET_ECO: + await self._device.set_control_mode(HMIP_ECO_CM) if preset_mode in self._device_profile_names: profile_idx = self._get_profile_idx_by_name(preset_mode) if self._device.controlMode != HMIP_AUTOMATIC_CM: diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index 199cbacfa15..46d036c777b 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -10,7 +10,8 @@ from homematicip.aio.group import AsyncGroup from homeassistant.const import ATTR_ID from homeassistant.core import callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN as HMIPC_DOMAIN from .hap import AsyncHome, HomematicipHAP diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index a8393ff88ac..09457ce0792 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -286,7 +286,7 @@ async def _set_active_climate_profile( async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None: """Service to dump the configuration of a Homematic IP Access Point.""" config_path: str = ( - service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir or "." + service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir ) config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 2aa1b0369d9..3279c9ba41b 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.const import ATTR_IDENTIFIERS -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 0168e4407f7..b23df9f1f4b 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -6,7 +6,8 @@ import datetime from typing import Any from aiohttp import ClientConnectionError -import aiosomecomfort +from aiosomecomfort import SomeComfortError, UnauthorizedError, UnexpectedResponse +from aiosomecomfort.device import Device as SomeComfortDevice from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, @@ -26,7 +27,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HoneywellData @@ -106,7 +107,7 @@ class HoneywellUSThermostat(ClimateEntity): def __init__( self, data: HoneywellData, - device: aiosomecomfort.device.Device, + device: SomeComfortDevice, cool_away_temp: int | None, heat_away_temp: int | None, ) -> None: @@ -312,7 +313,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode == "heat": await self._device.set_setpoint_heat(temperature) - except aiosomecomfort.SomeComfortError as err: + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_temperature(self, **kwargs: Any) -> None: @@ -325,7 +326,7 @@ class HoneywellUSThermostat(ClimateEntity): if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) - except aiosomecomfort.SomeComfortError as err: + except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -354,7 +355,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) - except aiosomecomfort.SomeComfortError: + except SomeComfortError: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, @@ -375,7 +376,7 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True) - except aiosomecomfort.SomeComfortError: + except SomeComfortError: _LOGGER.error("Couldn't set permanent hold") else: _LOGGER.error("Invalid system mode returned: %s", mode) @@ -387,7 +388,7 @@ class HoneywellUSThermostat(ClimateEntity): # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) - except aiosomecomfort.SomeComfortError: + except SomeComfortError: _LOGGER.error("Can not stop hold mode") async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -416,12 +417,14 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True - except aiosomecomfort.SomeComfortError: + except UnauthorizedError: try: await self._data.client.login() + await self._device.refresh() + self._attr_available = True except ( - aiosomecomfort.SomeComfortError, + SomeComfortError, ClientConnectionError, asyncio.TimeoutError, ): @@ -429,3 +432,6 @@ class HoneywellUSThermostat(ClimateEntity): except (ClientConnectionError, asyncio.TimeoutError): self._attr_available = False + + except UnexpectedResponse: + pass diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py index 94455d569cb..d5153a69f65 100644 --- a/homeassistant/components/honeywell/const.py +++ b/homeassistant/components/honeywell/const.py @@ -9,7 +9,4 @@ DEFAULT_COOL_AWAY_TEMPERATURE = 88 DEFAULT_HEAT_AWAY_TEMPERATURE = 61 CONF_DEV_ID = "thermostat" CONF_LOC_ID = "location" -TEMPERATURE_STATUS_KEY = "outdoor_temperature" -HUMIDITY_STATUS_KEY = "outdoor_humidity" - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index aa07a5248cf..a53eaaab8ce 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.15"] + "requirements": ["AIOSomecomfort==0.0.17"] } diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 6a91e493488..9542648b996 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -16,12 +16,17 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import HoneywellData -from .const import DOMAIN, HUMIDITY_STATUS_KEY, TEMPERATURE_STATUS_KEY +from .const import DOMAIN + +OUTDOOR_TEMPERATURE_STATUS_KEY = "outdoor_temperature" +OUTDOOR_HUMIDITY_STATUS_KEY = "outdoor_humidity" +CURRENT_TEMPERATURE_STATUS_KEY = "current_temperature" +CURRENT_HUMIDITY_STATUS_KEY = "current_humidity" def _get_temperature_sensor_unit(device: Device) -> str: @@ -48,21 +53,35 @@ class HoneywellSensorEntityDescription( SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = ( HoneywellSensorEntityDescription( - key=TEMPERATURE_STATUS_KEY, - translation_key="outdoor_temperature", + key=OUTDOOR_TEMPERATURE_STATUS_KEY, + translation_key=OUTDOOR_TEMPERATURE_STATUS_KEY, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.outdoor_temperature, unit_fn=_get_temperature_sensor_unit, ), HoneywellSensorEntityDescription( - key=HUMIDITY_STATUS_KEY, - translation_key="outdoor_humidity", + key=OUTDOOR_HUMIDITY_STATUS_KEY, + translation_key=OUTDOOR_HUMIDITY_STATUS_KEY, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda device: device.outdoor_humidity, unit_fn=lambda device: PERCENTAGE, ), + HoneywellSensorEntityDescription( + key=CURRENT_TEMPERATURE_STATUS_KEY, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.current_temperature, + unit_fn=_get_temperature_sensor_unit, + ), + HoneywellSensorEntityDescription( + key=CURRENT_HUMIDITY_STATUS_KEY, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.current_humidity, + unit_fn=lambda device: PERCENTAGE, + ), ) @@ -89,7 +108,7 @@ class HoneywellSensor(SensorEntity): entity_description: HoneywellSensorEntityDescription _attr_has_entity_name = True - def __init__(self, device, description): + def __init__(self, device, description) -> None: """Initialize the outdoor temperature sensor.""" self._device = device self.entity_description = description diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 931d446b2a0..d65a4c42488 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -113,7 +113,6 @@ SUBSCRIPTION_SCHEMA = vol.All( dict, vol.Schema( { - # pylint: disable=no-value-for-parameter vol.Required(ATTR_ENDPOINT): vol.Url(), vol.Required(ATTR_KEYS): KEYS_SCHEMA, vol.Optional(ATTR_EXPIRATIONTIME): vol.Any(None, cv.positive_int), diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 68602e34d3e..409b78fb16a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -40,7 +40,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start -from homeassistant.util import ssl as ssl_util +from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util.json import json_loads from .auth import async_setup_auth @@ -52,7 +52,9 @@ from .const import ( # noqa: F401 KEY_HASS_USER, ) from .cors import setup_cors +from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded +from .headers import setup_headers from .request_context import current_request, setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource @@ -69,6 +71,7 @@ CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate" CONF_SSL_KEY: Final = "ssl_key" CONF_CORS_ORIGINS: Final = "cors_allowed_origins" CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for" +CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options" CONF_TRUSTED_PROXIES: Final = "trusted_proxies" CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" @@ -118,6 +121,7 @@ HTTP_SCHEMA: Final = vol.All( vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( [SSL_INTERMEDIATE, SSL_MODERN] ), + vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, } ), ) @@ -136,6 +140,7 @@ class ConfData(TypedDict, total=False): ssl_key: str cors_allowed_origins: list[str] use_x_forwarded_for: bool + use_x_frame_options: bool trusted_proxies: list[IPv4Network | IPv6Network] login_attempts_threshold: int ip_ban_enabled: bool @@ -180,6 +185,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) + use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS] trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or [] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] @@ -200,6 +206,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: use_x_forwarded_for=use_x_forwarded_for, login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, + use_x_frame_options=use_x_frame_options, ) async def stop_server(event: Event) -> None: @@ -311,7 +318,7 @@ class HomeAssistantHTTP: # By default aiohttp does a linear search for routing rules, # we have a lot of routes, so use a dict lookup with a fallback # to the linear search. - self.app._router = FastUrlDispatcher() # pylint: disable=protected-access + self.app._router = FastUrlDispatcher() self.hass = hass self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate @@ -331,6 +338,7 @@ class HomeAssistantHTTP: use_x_forwarded_for: bool, login_threshold: int, is_ban_enabled: bool, + use_x_frame_options: bool, ) -> None: """Initialize the server.""" self.app[KEY_HASS] = self.hass @@ -348,6 +356,7 @@ class HomeAssistantHTTP: await async_setup_auth(self.hass, self.app) + setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) if self.ssl_certificate: @@ -494,14 +503,15 @@ class HomeAssistantHTTP: x509.NameAttribute(NameOID.COMMON_NAME, host), ] ) + now = dt_util.utcnow() cert = ( x509.CertificateBuilder() .subject_name(subject) .issuer_name(issuer) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) - .not_valid_before(datetime.datetime.utcnow()) - .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=30)) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=30)) .add_extension( x509.SubjectAlternativeName([x509.DNSName(host)]), critical=False, diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py new file mode 100644 index 00000000000..ce5b1b18c06 --- /dev/null +++ b/homeassistant/components/http/decorators.py @@ -0,0 +1,73 @@ +"""Decorators for the Home Assistant API.""" +from __future__ import annotations + +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 homeassistant.exceptions import Unauthorized + +from .view import HomeAssistantView + +_HomeAssistantViewT = TypeVar("_HomeAssistantViewT", bound=HomeAssistantView) +_P = ParamSpec("_P") +_FuncType = Callable[ + Concatenate[_HomeAssistantViewT, Request, _P], Coroutine[Any, Any, Response] +] + + +@overload +def require_admin( + _func: None = None, + *, + error: Unauthorized | None = None, +) -> Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]]: + ... + + +@overload +def require_admin( + _func: _FuncType[_HomeAssistantViewT, _P], +) -> _FuncType[_HomeAssistantViewT, _P]: + ... + + +def require_admin( + _func: _FuncType[_HomeAssistantViewT, _P] | None = None, + *, + error: Unauthorized | None = None, +) -> ( + Callable[[_FuncType[_HomeAssistantViewT, _P]], _FuncType[_HomeAssistantViewT, _P]] + | _FuncType[_HomeAssistantViewT, _P] +): + """Home Assistant API decorator to require user to be an admin.""" + + def decorator_require_admin( + func: _FuncType[_HomeAssistantViewT, _P] + ) -> _FuncType[_HomeAssistantViewT, _P]: + """Wrap the provided with_admin function.""" + + @wraps(func) + async def with_admin( + self: _HomeAssistantViewT, + request: Request, + *args: _P.args, + **kwargs: _P.kwargs, + ) -> Response: + """Check admin and call function.""" + if not request["hass_user"].is_admin: + raise error or Unauthorized() + + return await func(self, request, *args, **kwargs) + + return with_admin + + # See if we're being called as @require_admin or @require_admin(). + if _func is None: + # We're called with brackets. + return decorator_require_admin + + # We're called as @require_admin without brackets. + return decorator_require_admin(_func) diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py new file mode 100644 index 00000000000..20c0a58967b --- /dev/null +++ b/homeassistant/components/http/headers.py @@ -0,0 +1,42 @@ +"""Middleware that helps with the control of headers in our responses.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from aiohttp.web import Application, Request, StreamResponse, middleware +from aiohttp.web_exceptions import HTTPException + +from homeassistant.core import callback + + +@callback +def setup_headers(app: Application, use_x_frame_options: bool) -> None: + """Create headers middleware for the app.""" + + added_headers = { + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Server": "", # Empty server header, to prevent aiohttp of setting one. + } + + if use_x_frame_options: + added_headers["X-Frame-Options"] = "SAMEORIGIN" + + @middleware + async def headers_middleware( + request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] + ) -> StreamResponse: + """Process request and add headers to the responses.""" + try: + response = await handler(request) + except HTTPException as err: + for key, value in added_headers.items(): + err.headers[key] = value + raise err + + for key, value in added_headers.items(): + response.headers[key] = value + + return response + + app.middlewares.append(headers_middleware) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 3c101dff9cc..f21f084a544 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -46,8 +46,9 @@ from homeassistant.helpers import ( discovery, entity_registry as er, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py index ab787a97ea9..172e8658928 100644 --- a/homeassistant/components/huawei_lte/utils.py +++ b/homeassistant/components/huawei_lte/utils.py @@ -21,7 +21,7 @@ def get_device_macs( for x in ("MacAddress1", "MacAddress2", "WifiMacAddrWl0", "WifiMacAddrWl1") ] # Assume not supported when exception is thrown - with suppress(Exception): # pylint: disable=broad-except + with suppress(Exception): macs.extend(x.get("WifiMac") for x in wlan_settings["Ssids"]["Ssid"]) return sorted({format_mac(str(x)) for x in macs if x}) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 0e1688221b3..04bd63e5b1f 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -10,7 +10,6 @@ import aiohttp from aiohttp import client_exceptions from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized from aiohue.errors import AiohueException, BridgeBusy -import async_timeout from homeassistant import core from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -73,7 +72,7 @@ class HueBridge: async def async_initialize_bridge(self) -> bool: """Initialize Connection with the Hue API.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self.api.initialize() except (LinkButtonNotPressed, Unauthorized): diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 2b0ebdebcaa..9c8dda94c94 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -9,7 +9,6 @@ import aiohttp from aiohue import LinkButtonNotPressed, create_app_key from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp from aiohue.util import normalize_bridge_id -import async_timeout import slugify as unicode_slug import voluptuous as vol @@ -110,7 +109,7 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Find / discover bridges try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): bridges = await discover_nupnp( websession=aiohttp_client.async_get_clientsession(self.hass) ) diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 8e34f7a22bf..914067509b7 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -6,10 +6,7 @@ from typing import Any from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType from aiohue.v2.models.button import Button -from aiohue.v2.models.relative_rotary import ( - RelativeRotary, - RelativeRotaryDirection, -) +from aiohue.v2.models.relative_rotary import RelativeRotary, RelativeRotaryDirection from homeassistant.components.event import ( EventDeviceClass, diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 2c6c1679779..bd290d0bbb8 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -13,8 +13,7 @@ import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index f0ba0dbac23..18440f68239 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -1,13 +1,13 @@ """Support for the Philips Hue lights.""" from __future__ import annotations +import asyncio from datetime import timedelta from functools import partial import logging import random import aiohue -import async_timeout from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -28,7 +28,7 @@ from homeassistant.components.light import ( from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -224,7 +224,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): # Once we do a rooms update, we cancel the listener # until the next time lights are added bridge.reset_jobs.remove(cancel_update_rooms_listener) - cancel_update_rooms_listener() # pylint: disable=not-callable + cancel_update_rooms_listener() cancel_update_rooms_listener = None @callback @@ -262,7 +262,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_safe_fetch(bridge, fetch_method): """Safely fetch data.""" try: - async with async_timeout.timeout(4): + async with asyncio.timeout(4): return await bridge.async_request_call(fetch_method) except aiohue.Unauthorized as err: await bridge.handle_unauthorized_error() diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index 84921707f2a..723ecfff451 100644 --- a/homeassistant/components/hue/v1/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -1,13 +1,13 @@ """Support for the Philips Hue sensors as a platform.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any from aiohue import AiohueException, Unauthorized from aiohue.v1.sensors import TYPE_ZLL_PRESENCE -import async_timeout from homeassistant.components.sensor import SensorStateClass from homeassistant.core import callback @@ -61,7 +61,7 @@ class SensorManager: async def async_update_data(self): """Update sensor data.""" try: - async with async_timeout.timeout(4): + async with asyncio.timeout(4): return await self.bridge.async_request_call( self.bridge.api.sensors.update ) diff --git a/homeassistant/components/hue/v1/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py index 176b5f118b2..9ffc1518cba 100644 --- a/homeassistant/components/hue/v1/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -1,5 +1,6 @@ """Support for the Philips Hue sensor devices.""" from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo from ..const import ( CONF_ALLOW_UNREACHABLE, @@ -47,12 +48,12 @@ class GenericHueDevice(entity.Entity): return self.primary_sensor.raw.get("swupdate", {}).get("state") @property - def device_info(self) -> entity.DeviceInfo: + def device_info(self) -> DeviceInfo: """Return the device info. Links individual entities together in the hass device registry. """ - return entity.DeviceInfo( + return DeviceInfo( identifiers={(HUE_DOMAIN, self.device_id)}, manufacturer=self.primary_sensor.manufacturername, model=(self.primary_sensor.productname or self.primary_sensor.modelid), diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index ef01b2e4693..f4c76618009 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -9,8 +9,11 @@ from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.zigbee_connectivity import ConnectivityServiceStatus from homeassistant.core import callback -from homeassistant.helpers.device_registry import async_get as async_get_device_registry -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import ( + DeviceInfo, + async_get as async_get_device_registry, +) +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry from ..bridge import HueBridge diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 1cb862e3d77..9985d37627b 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -21,8 +21,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from ..bridge import HueBridge diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py index 8559156379b..b1c2d865e0c 100644 --- a/homeassistant/components/huisbaasje/__init__.py +++ b/homeassistant/components/huisbaasje/__init__.py @@ -1,9 +1,9 @@ """The Huisbaasje integration.""" +import asyncio from datetime import timedelta import logging from typing import Any -import async_timeout from energyflip import EnergyFlip, EnergyFlipException from homeassistant.config_entries import ConfigEntry @@ -86,7 +86,7 @@ async def async_update_huisbaasje(energyflip: EnergyFlip) -> dict[str, dict[str, try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(FETCH_TIMEOUT): + async with asyncio.timeout(FETCH_TIMEOUT): if not energyflip.is_authenticated(): _LOGGER.warning("Huisbaasje is unauthenticated. Reauthenticating") await energyflip.authenticate() diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 4b0d666d2ae..56ebbe6fb26 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -1,4 +1,5 @@ """The Hunter Douglas PowerView integration.""" +import asyncio import logging from aiopvapi.helpers.aiorequest import AioRequest @@ -8,7 +9,6 @@ from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes from aiopvapi.shades import Shades from aiopvapi.userdata import UserData -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform @@ -63,20 +63,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): device_info = await async_get_device_info(pv_request, hub_address) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): rooms = Rooms(pv_request) room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): scenes = Scenes(pv_request) scene_data = async_map_data_by_id( (await scenes.get_resources())[SCENE_DATA] ) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): shades = Shades(pv_request) shade_entries = await shades.get_resources() shade_data = async_map_data_by_id(shade_entries[SHADE_DATA]) diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index cb2da3ba8fa..2e0bc1c413a 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -7,7 +7,11 @@ from typing import Any, Final from aiopvapi.resources.shade import BaseShade, factory as PvShade -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -36,21 +40,20 @@ class PowerviewButtonDescription( BUTTONS: Final = [ PowerviewButtonDescription( key="calibrate", - name="Calibrate", + translation_key="calibrate", icon="mdi:swap-vertical-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.calibrate(), ), PowerviewButtonDescription( key="identify", - name="Identify", - icon="mdi:crosshairs-question", + device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.jog(), ), PowerviewButtonDescription( key="favorite", - name="Favorite", + translation_key="favorite", icon="mdi:heart", entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.favorite(), @@ -104,7 +107,6 @@ class PowerviewButton(ShadeEntity, ButtonEntity): """Initialize the button entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description: PowerviewButtonDescription = description - self._attr_name = f"{self._shade_name} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" async def async_press(self) -> None: diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 7c9bdfcf244..8c6d0fc4dd3 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -1,10 +1,10 @@ """Config flow for Hunter Douglas PowerView integration.""" from __future__ import annotations +import asyncio import logging from aiopvapi.helpers.aiorequest import AioRequest -import async_timeout import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -34,7 +34,7 @@ async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): device_info = await async_get_device_info(pv_request, hub_address) except HUB_EXCEPTIONS as err: raise CannotConnect from err diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 203aea6c49f..4643536d56d 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -1,11 +1,11 @@ """Coordinate data for powerview devices.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from aiopvapi.shades import Shades -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -37,7 +37,7 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) async def _async_update_data(self) -> PowerviewShadeData: """Fetch data from shade endpoint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): shade_entries = await self.shades.get_resources() if isinstance(shade_entries, bool): diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index dfb1a7ad967..833c1812ddb 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -19,7 +19,6 @@ from aiopvapi.helpers.constants import ( MIN_POSITION, ) from aiopvapi.resources.shade import BaseShade, factory as PvShade -import async_timeout from homeassistant.components.cover import ( ATTR_POSITION, @@ -84,7 +83,7 @@ async def async_setup_entry( shade: BaseShade = PvShade(raw_shade, pv_entry.api) name_before_refresh = shade.name with suppress(asyncio.TimeoutError): - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await shade.refresh() if ATTR_POSITION_DATA not in shade.raw_data: @@ -118,7 +117,11 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" _attr_device_class = CoverDeviceClass.SHADE - _attr_supported_features = CoverEntityFeature(0) + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) def __init__( self, @@ -131,7 +134,6 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._shade: BaseShade = shade - self._attr_name = self._shade_name self._scheduled_transition_update: CALLBACK_TYPE | None = None if self._device_info.model != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP @@ -346,26 +348,14 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): class PowerViewShade(PowerViewShadeBase): """Represent a standard shade.""" - def __init__( - self, - coordinator: PowerviewShadeUpdateCoordinator, - device_info: PowerviewDeviceInfo, - room_name: str, - shade: BaseShade, - name: str, - ) -> None: - """Initialize the shade.""" - super().__init__(coordinator, device_info, room_name, shade, name) - self._attr_supported_features |= ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + _attr_name = None -class PowerViewShadeWithTiltBase(PowerViewShade): +class PowerViewShadeWithTiltBase(PowerViewShadeBase): """Representation for PowerView shades with tilt capabilities.""" + _attr_name = None + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -453,9 +443,11 @@ class PowerViewShadeWithTiltOnClosed(PowerViewShadeWithTiltBase): API Class: ShadeBottomUpTiltOnClosed + ShadeBottomUpTiltOnClosed90 Type 1 - Bottom Up w/ 90° Tilt - Shade 44 - a shade thought to have been a firmware issue (type 0 usually dont tilt) + Shade 44 - a shade thought to have been a firmware issue (type 0 usually don't tilt) """ + _attr_name = None + @property def open_position(self) -> PowerviewShadeMove: """Return the open position and required additional positions.""" @@ -570,7 +562,7 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): self._max_tilt = self._shade.shade_limits.tilt_max -class PowerViewShadeTopDown(PowerViewShade): +class PowerViewShadeTopDown(PowerViewShadeBase): """Representation of a shade that lowers from the roof to the floor. These shades are inverted where MAX_POSITION equates to closed and MIN_POSITION is open @@ -579,6 +571,8 @@ class PowerViewShadeTopDown(PowerViewShade): Type 6 - Top Down """ + _attr_name = None + @property def current_cover_position(self) -> int: """Return the current position of cover.""" @@ -594,7 +588,7 @@ class PowerViewShadeTopDown(PowerViewShade): await self._async_set_cover_position(100 - kwargs[ATTR_POSITION]) -class PowerViewShadeDualRailBase(PowerViewShade): +class PowerViewShadeDualRailBase(PowerViewShadeBase): """Representation of a shade with top/down bottom/up capabilities. Base methods shared between the two shades created @@ -613,11 +607,13 @@ class PowerViewShadeDualRailBase(PowerViewShade): class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): """Representation of the bottom PowerViewShadeDualRailBase shade. - These shades have top/down bottom up functionality and two entiites. + These shades have top/down bottom up functionality and two entities. Sibling Class: PowerViewShadeTDBUTop API Class: ShadeTopDownBottomUp """ + _attr_translation_key = "bottom" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -629,7 +625,6 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_bottom" - self._attr_name = f"{self._shade_name} Bottom" @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: @@ -655,11 +650,13 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): """Representation of the top PowerViewShadeDualRailBase shade. - These shades have top/down bottom up functionality and two entiites. + These shades have top/down bottom up functionality and two entities. Sibling Class: PowerViewShadeTDBUBottom API Class: ShadeTopDownBottomUp """ + _attr_translation_key = "top" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -671,7 +668,6 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_top" - self._attr_name = f"{self._shade_name} Top" @property def should_poll(self) -> bool: @@ -711,7 +707,7 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" + """Don't allow a cover to go into an impossbile position.""" cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) return min(target_hass_position, (100 - cover_bottom)) @@ -730,7 +726,7 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): ) -class PowerViewShadeDualOverlappedBase(PowerViewShade): +class PowerViewShadeDualOverlappedBase(PowerViewShadeBase): """Represent a shade that has a front sheer and rear opaque panel. This equates to two shades being controlled by one motor @@ -783,6 +779,8 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): Type 8 - Duolite (front and rear shades) """ + _attr_translation_key = "combined" + # type def __init__( self, @@ -795,7 +793,6 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_combined" - self._attr_name = f"{self._shade_name} Combined" @property def is_closed(self) -> bool: @@ -842,7 +839,7 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): - """Represent the shade front panel - These have a opaque panel too. + """Represent the shade front panel - These have an opaque panel too. This equates to two shades being controlled by one motor. The front shade must be completely down before the rear shade will move. @@ -857,6 +854,8 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): Type 10 - Duolite with 180° Tilt """ + _attr_translation_key = "front" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -868,7 +867,6 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_front" - self._attr_name = f"{self._shade_name} Front" @property def should_poll(self) -> bool: @@ -906,7 +904,7 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): - """Represent the shade front panel - These have a opaque panel too. + """Represent the shade front panel - These have an opaque panel too. This equates to two shades being controlled by one motor. The front shade must be completely down before the rear shade will move. @@ -921,6 +919,8 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): Type 10 - Duolite with 180° Tilt """ + _attr_translation_key = "rear" + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -932,7 +932,6 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) self._attr_unique_id = f"{self._shade.id}_rear" - self._attr_name = f"{self._shade_name} Rear" @property def should_poll(self) -> bool: diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 655eb572c31..78f63e16879 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -4,7 +4,7 @@ from aiopvapi.resources.shade import ATTR_TYPE, BaseShade from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -25,6 +25,8 @@ from .shade_data import PowerviewShadeData, PowerviewShadePositions class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): """Base class for hunter douglas entities.""" + _attr_has_entity_name = True + def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 7de7d3e8735..37d1193e0e5 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -47,7 +47,7 @@ class PowerviewSelectDescription( DROPDOWNS: Final = [ PowerviewSelectDescription( key="powersource", - name="Power Source", + translation_key="power_source", icon="mdi:power-plug-outline", current_fn=lambda shade: POWER_SUPPLY_TYPE_MAP.get( shade.raw_data.get(ATTR_BATTERY_KIND), None @@ -106,7 +106,6 @@ class PowerViewSelect(ShadeEntity, SelectEntity): """Initialize the select entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description: PowerviewSelectDescription = description - self._attr_name = f"{self._shade_name} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" @property diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index b36457324e1..825ca140f14 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -55,7 +55,6 @@ class PowerviewSensorDescription( SENSORS: Final = [ PowerviewSensorDescription( key="charge", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, native_value_fn=lambda shade: round( @@ -69,7 +68,7 @@ SENSORS: Final = [ ), PowerviewSensorDescription( key="signal", - name="Signal", + translation_key="signal_strength", icon="mdi:signal", native_unit_of_measurement=PERCENTAGE, native_value_fn=lambda shade: round( @@ -129,7 +128,6 @@ class PowerViewSensor(ShadeEntity, SensorEntity): """Initialize the select entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description = description - self._attr_name = f"{self._shade_name} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_native_unit_of_measurement = description.native_unit_of_measurement diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index ec26e423e06..7c17788be83 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -20,5 +20,42 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "button": { + "calibrate": { + "name": "Calibrate" + }, + "favorite": { + "name": "Favorite" + } + }, + "cover": { + "bottom": { + "name": "Bottom" + }, + "top": { + "name": "Top" + }, + "combined": { + "name": "Combined" + }, + "front": { + "name": "Front" + }, + "rear": { + "name": "Rear" + } + }, + "select": { + "power_source": { + "name": "Power source" + } + }, + "sensor": { + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + } + } } } diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index a50b2c4d09b..513c8dbd8b0 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -1,12 +1,12 @@ """Binary sensor platform for hvv_departures.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any from aiohttp import ClientConnectorError -import async_timeout from pygti.exceptions import InvalidAuth from homeassistant.components.binary_sensor import ( @@ -15,8 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -91,7 +90,7 @@ async def async_setup_entry( payload = {"station": {"id": station["id"], "type": station["type"]}} try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return get_elevator_entities_from_station_information( station_name, await hub.gti.stationInformation(payload) ) diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index c58ae6e3931..76a7966a6ed 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -11,8 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle from homeassistant.util.dt import get_time_zone, utcnow diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index d9e6d809960..f9de9bf30c9 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2023.7.1"] + "requirements": ["pydrawise==2023.8.0"] } diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 0366816ef1a..9c9e509947d 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -27,11 +27,11 @@ from homeassistant.components.camera import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( @@ -44,7 +44,6 @@ from .const import ( DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, - NAME_SUFFIX_HYPERION_CAMERA, SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_CAMERA, ) @@ -107,6 +106,9 @@ async def async_setup_entry( class HyperionCamera(Camera): """ComponentBinarySwitch switch class.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, server_id: str, @@ -120,7 +122,6 @@ class HyperionCamera(Camera): self._unique_id = get_hyperion_unique_id( server_id, instance_num, TYPE_HYPERION_CAMERA ) - self._name = f"{instance_name} {NAME_SUFFIX_HYPERION_CAMERA}".strip() self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._client = hyperion_client @@ -140,11 +141,6 @@ class HyperionCamera(Camera): """Return a unique id for this instance.""" return self._unique_id - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._name - @property def is_on(self) -> bool: """Return true if the camera is on.""" diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 4585b8bedaa..77e16df4d72 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -21,9 +21,6 @@ HYPERION_MODEL_NAME = f"{HYPERION_MANUFACTURER_NAME}-NG" HYPERION_RELEASES_URL = "https://github.com/hyperion-project/hyperion.ng/releases" HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9" -NAME_SUFFIX_HYPERION_COMPONENT_SWITCH = "Component" -NAME_SUFFIX_HYPERION_CAMERA = "" - SIGNAL_INSTANCE_ADD = f"{DOMAIN}_instance_add_signal.{{}}" SIGNAL_INSTANCE_REMOVE = f"{DOMAIN}_instance_remove_signal.{{}}" SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal.{{}}" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index e14c395315e..105e577efad 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -19,11 +19,11 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util @@ -116,6 +116,8 @@ async def async_setup_entry( class HyperionLight(LightEntity): """A Hyperion light that acts as a client for the configured priority.""" + _attr_has_entity_name = True + _attr_name = None _attr_color_mode = ColorMode.HS _attr_should_poll = False _attr_supported_color_modes = {ColorMode.HS} @@ -131,7 +133,6 @@ class HyperionLight(LightEntity): ) -> None: """Initialize the light.""" self._unique_id = self._compute_unique_id(server_id, instance_num) - self._name = self._compute_name(instance_name) self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._options = options @@ -157,20 +158,11 @@ class HyperionLight(LightEntity): """Compute a unique id for this instance.""" return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) - def _compute_name(self, instance_name: str) -> str: - """Compute the name of the light.""" - return f"{instance_name}".strip() - @property def entity_registry_enabled_default(self) -> bool: """Whether or not the entity is enabled by default.""" return True - @property - def name(self) -> str: - """Return the name of the light.""" - return self._name - @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index 54beb7704c9..a2f8838e2ea 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -50,5 +50,33 @@ } } } + }, + "entity": { + "switch": { + "all": { + "name": "Component all" + }, + "smoothing": { + "name": "Component smoothing" + }, + "blackbar_detection": { + "name": "Component blackbar detection" + }, + "forwarder": { + "name": "Component forwarder" + }, + "boblight_server": { + "name": "Component boblight server" + }, + "platform_capture": { + "name": "Component platform capture" + }, + "led_device": { + "name": "Component LED device" + }, + "usb_capture": { + "name": "Component USB capture" + } + } } } diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index dde2a5c29c5..11e1dc199be 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -28,11 +28,11 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -46,7 +46,6 @@ from .const import ( DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, - NAME_SUFFIX_HYPERION_COMPONENT_SWITCH, SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_COMPONENT_SWITCH_BASE, ) @@ -74,13 +73,17 @@ def _component_to_unique_id(server_id: str, component: str, instance_num: int) - ) -def _component_to_switch_name(component: str, instance_name: str) -> str: - """Convert a component to a switch name.""" - return ( - f"{instance_name} " - f"{NAME_SUFFIX_HYPERION_COMPONENT_SWITCH} " - f"{KEY_COMPONENTID_TO_NAME.get(component, component.capitalize())}" - ) +def _component_to_translation_key(component: str) -> str: + return { + KEY_COMPONENTID_ALL: "all", + KEY_COMPONENTID_SMOOTHING: "smoothing", + KEY_COMPONENTID_BLACKBORDER: "blackbar_detection", + KEY_COMPONENTID_FORWARDER: "forwarder", + KEY_COMPONENTID_BOBLIGHTSERVER: "boblight_server", + KEY_COMPONENTID_GRABBER: "platform_capture", + KEY_COMPONENTID_LEDDEVICE: "led_device", + KEY_COMPONENTID_V4L: "usb_capture", + }[component] async def async_setup_entry( @@ -129,6 +132,7 @@ class HyperionComponentSwitch(SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, @@ -143,7 +147,7 @@ class HyperionComponentSwitch(SwitchEntity): server_id, component_name, instance_num ) self._device_id = get_hyperion_device_id(server_id, instance_num) - self._name = _component_to_switch_name(component_name, instance_name) + self._attr_translation_key = _component_to_translation_key(component_name) self._instance_name = instance_name self._component_name = component_name self._client = hyperion_client @@ -162,11 +166,6 @@ class HyperionComponentSwitch(SwitchEntity): """Return a unique id for this instance.""" return self._unique_id - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._name - @property def is_on(self) -> bool: """Return true if the switch is on.""" diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index b258c702725..b2c1800914e 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import logging -from async_timeout import timeout from pyialarm import IAlarm from homeassistant.components.alarm_control_panel import SCAN_INTERVAL @@ -27,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ialarm = IAlarm(host, port) try: - async with timeout(10): + async with asyncio.timeout(10): mac = await hass.async_add_executor_job(ialarm.get_mac) except (asyncio.TimeoutError, ConnectionError) as ex: raise ConfigEntryNotReady from ex @@ -81,7 +80,7 @@ class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from iAlarm.""" try: - async with timeout(10): + async with asyncio.timeout(10): await self.hass.async_add_executor_job(self._update_data) except ConnectionError as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index 1981a56e211..b09e31f5312 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -7,7 +7,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index 206b5def832..ca468200370 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -5,7 +5,6 @@ import asyncio from datetime import timedelta import logging -import async_timeout from iammeter import real_time_api from iammeter.power_meter import IamMeterError import voluptuous as vol @@ -52,7 +51,7 @@ async def async_setup_platform( config_port = config[CONF_PORT] config_name = config[CONF_NAME] try: - async with async_timeout.timeout(PLATFORM_TIMEOUT): + async with asyncio.timeout(PLATFORM_TIMEOUT): api = await real_time_api(config_host, config_port) except (IamMeterError, asyncio.TimeoutError) as err: _LOGGER.error("Device is not ready") @@ -60,7 +59,7 @@ async def async_setup_platform( async def async_update_data(): try: - async with async_timeout.timeout(PLATFORM_TIMEOUT): + async with asyncio.timeout(PLATFORM_TIMEOUT): return await api.get_data() except (IamMeterError, asyncio.TimeoutError) as err: raise UpdateFailed from err diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 5735e1ab421..9554d30df45 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -29,11 +29,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from .const import DOMAIN, UPDATE_INTERVAL diff --git a/homeassistant/components/ibeacon/entity.py b/homeassistant/components/ibeacon/entity.py index 4baa06dd617..b25c82037e1 100644 --- a/homeassistant/components/ibeacon/entity.py +++ b/homeassistant/components/ibeacon/entity.py @@ -6,8 +6,9 @@ from abc import abstractmethod from ibeacon_ble import iBeaconAdvertisement from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import ATTR_MAJOR, ATTR_MINOR, ATTR_SOURCE, ATTR_UUID, DOMAIN from .coordinator import IBeaconCoordinator, signal_seen, signal_unavailable diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 6cabe51fff5..0bd1dfb44a9 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -6,8 +6,8 @@ from typing import Any from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .account import IcloudAccount, IcloudDevice diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 01aabc5871c..e92a9ae4a8d 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -7,8 +7,8 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index e4bc1664fd9..d1895053f02 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -11,7 +11,6 @@ from random import SystemRandom from typing import Final, final from aiohttp import hdrs, web -import async_timeout import httpx from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView @@ -72,7 +71,7 @@ def valid_image_content_type(content_type: str | None) -> str: async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: """Fetch image from an image entity.""" with suppress(asyncio.CancelledError, asyncio.TimeoutError, ImageContentTypeError): - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): if image_bytes := await image_entity.async_image(): content_type = valid_image_content_type(image_entity.content_type) image = Image(content_type, image_bytes) diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 569df9c65e4..6faa690b4cb 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -78,8 +78,12 @@ class ImageStorageCollection(collection.DictStorageCollection): data = self.CREATE_SCHEMA(dict(data)) uploaded_file: FileField = data["file"] - if not uploaded_file.content_type.startswith("image/"): - raise vol.Invalid("Only images are allowed") + if uploaded_file.content_type not in ( + "image/gif", + "image/jpeg", + "image/png", + ): + raise vol.Invalid("Only jpeg, png, and gif images are allowed") data[CONF_ID] = secrets.token_hex(16) data["filesize"] = await self.hass.async_add_executor_job(self._move_data, data) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index b644c300979..72be5e9bcf0 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -11,7 +11,6 @@ import logging from typing import Any from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -66,14 +65,28 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: else: ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) - + _LOGGER.debug( + "Wait for hello message from server %s on port %s, verify_ssl: %s", + data[CONF_SERVER], + data[CONF_PORT], + data.get(CONF_VERIFY_SSL, True), + ) await client.wait_hello_from_server() - if client.protocol.state == NONAUTH: + _LOGGER.debug( + "Authenticating with %s on server %s", + data[CONF_USERNAME], + data[CONF_SERVER], + ) await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) if client.protocol.state not in {AUTH, SELECTED}: raise InvalidAuth("Invalid username or password") if client.protocol.state == AUTH: + _LOGGER.debug( + "Selecting mail folder %s on server %s", + data[CONF_FOLDER], + data[CONF_SERVER], + ) await client.select(data[CONF_FOLDER]) if client.protocol.state != SELECTED: raise InvalidFolder(f"Folder {data[CONF_FOLDER]} is invalid") @@ -313,6 +326,9 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry ) -> None: """Initiate imap client.""" + _LOGGER.debug( + "Connected to server %s using IMAP polling", entry.data[CONF_SERVER] + ) super().__init__(hass, imap_client, entry, timedelta(seconds=10)) async def _async_update_data(self) -> int | None: @@ -355,6 +371,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry ) -> None: """Initiate imap client.""" + _LOGGER.debug("Connected to server %s using IMAP push", entry.data[CONF_SERVER]) super().__init__(hass, imap_client, entry, None) self._push_wait_task: asyncio.Task[None] | None = None @@ -408,7 +425,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): idle: asyncio.Future = await self.imap_client.idle_start() await self.imap_client.wait_server_push() self.imap_client.idle_done() - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await idle # From python 3.11 asyncio.TimeoutError is an alias of TimeoutError diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index cd6da667ccb..92a66fabe49 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -9,8 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json index f84435971bf..b7b987b1212 100644 --- a/homeassistant/components/imap_email_content/strings.json +++ b/homeassistant/components/imap_email_content/strings.json @@ -2,7 +2,7 @@ "issues": { "deprecation": { "title": "The IMAP email content integration is deprecated", - "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." }, "migration": { "title": "The IMAP email content integration needs attention", diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py index dd7acc65458..d1762fa8d35 100644 --- a/homeassistant/components/insteon/insteon_entity.py +++ b/homeassistant/components/insteon/insteon_entity.py @@ -5,11 +5,12 @@ import logging from pyinsteon import devices from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import ( DOMAIN, diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 5ce64de9b33..66a99b63681 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -32,7 +32,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( EventStateChangedData, @@ -207,7 +207,6 @@ async def async_setup_platform( async_add_entities([integral]) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class IntegrationSensor(RestoreSensor): """Representation of an integration sensor.""" @@ -298,18 +297,14 @@ class IntegrationSensor(RestoreSensor): old_state = event.data["old_state"] new_state = event.data["new_state"] - # We may want to update our state before an early return, - # based on the source sensor's unit_of_measurement - # or device_class. - update_state = False - if ( source_state := self.hass.states.get(self._sensor_source_id) ) is None or source_state.state == STATE_UNAVAILABLE: self._attr_available = False - update_state = True - else: - self._attr_available = True + self.async_write_ha_state() + return + + self._attr_available = True if old_state is None or new_state is None: # we can't calculate the elapsed time, so we can't calculate the integral @@ -317,10 +312,7 @@ class IntegrationSensor(RestoreSensor): unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if unit is not None: - new_unit_of_measurement = self._unit(unit) - if self._unit_of_measurement != new_unit_of_measurement: - self._unit_of_measurement = new_unit_of_measurement - update_state = True + self._unit_of_measurement = self._unit(unit) if ( self.device_class is None @@ -329,10 +321,8 @@ class IntegrationSensor(RestoreSensor): ): self._attr_device_class = SensorDeviceClass.ENERGY self._attr_icon = None - update_state = True - if update_state: - self.async_write_ha_state() + self.async_write_ha_state() try: # integration as the Riemann integral of previous measures. diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index 5003ed91437..4045c19217b 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -1,15 +1,15 @@ """The IntelliFire integration.""" from __future__ import annotations +import asyncio from datetime import timedelta from aiohttp import ClientConnectionError -from async_timeout import timeout from intellifire4py import IntellifirePollData from intellifire4py.intellifire import IntellifireAPILocal from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -38,7 +38,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData await self._api.start_background_polling() # Don't return uninitialized poll data - async with timeout(15): + async with asyncio.timeout(15): try: await self._api.poll() except (ConnectionError, ClientConnectionError) as exception: diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 052ed9f94a0..dd5ea743d57 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -293,7 +293,6 @@ async def async_setup_entry( return True -# pylint: disable=invalid-name class iOSPushConfigView(HomeAssistantView): """A view that provides the push categories configuration.""" diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 519bb87d98a..2f42edb4bc1 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -25,7 +25,6 @@ _LOGGER = logging.getLogger(__name__) PUSH_URL = "https://ios-push.home-assistant.io/push" -# pylint: disable=invalid-name def log_rate_limits(hass, target, resp, level=20): """Output rate limit log line at given level.""" rate_limits = resp["rateLimits"] diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index f3767be9f3d..45cd3586af2 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -9,8 +9,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index b616c7e4ae9..27ecc1574e3 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity, entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -182,9 +182,9 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): return self._sensor_data.getName() @property - def device_info(self) -> entity.DeviceInfo: + def device_info(self) -> dr.DeviceInfo: """Return device info.""" - return entity.DeviceInfo( + return dr.DeviceInfo( connections={ (dr.CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) }, diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index dd46593998e..5ff89fa8ed5 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -2,7 +2,6 @@ import asyncio import logging -import async_timeout from pyipma import IPMAException from pyipma.api import IPMA_API from pyipma.location import Location @@ -32,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b api = IPMA_API(async_get_clientsession(hass)) try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): location = await Location.get(api, float(latitude), float(longitude)) except (IPMAException, asyncio.TimeoutError) as err: raise ConfigEntryNotReady( diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index eb361d3f9d5..cdea88bdbc0 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -1,54 +1,64 @@ """Config flow to configure IPMA component.""" +import logging +from typing import Any + +from pyipma import IPMAException +from pyipma.api import IPMA_API +from pyipma.location import Location import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, FORECAST_MODE, HOME_LOCATION_NAME +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) -class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class IpmaFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for IPMA component.""" VERSION = 1 - def __init__(self): - """Init IpmaFlowHandler.""" - self._errors = {} - - 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.""" - self._errors = {} + errors = {} if user_input is not None: - if user_input[CONF_NAME] not in self.hass.config_entries.async_entries( - DOMAIN - ): - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + self._async_abort_entries_match(user_input) + + api = IPMA_API(async_get_clientsession(self.hass)) + + try: + location = await Location.get( + api, + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], ) + except IPMAException as err: + _LOGGER.exception(err) + errors["base"] = "unknown" + else: + return self.async_create_entry(title=location.name, data=user_input) - self._errors[CONF_NAME] = "name_exists" - - # default location is set hass configuration - return await self._show_config_form( - name=HOME_LOCATION_NAME, - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, - ) - - async def _show_config_form(self, name=None, latitude=None, longitude=None): - """Show the configuration form to edit location data.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema( + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } + ), { - vol.Required(CONF_NAME, default=name): str, - vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, - vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, - vol.Required(CONF_MODE, default="daily"): vol.In(FORECAST_MODE), - } + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + }, ), - errors=self._errors, + errors=errors, ) diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index 2d715011e43..26fdee779b6 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -1,4 +1,6 @@ """Constants for IPMA component.""" +from __future__ import annotations + from datetime import timedelta from homeassistant.components.weather import ( @@ -31,7 +33,7 @@ ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) -CONDITION_CLASSES = { +CONDITION_CLASSES: dict[str, list[int]] = { ATTR_CONDITION_CLOUDY: [4, 5, 24, 25, 27], ATTR_CONDITION_FOG: [16, 17, 26], ATTR_CONDITION_HAIL: [21, 22], @@ -48,7 +50,10 @@ CONDITION_CLASSES = { ATTR_CONDITION_EXCEPTIONAL: [], ATTR_CONDITION_CLEAR_NIGHT: [-1], } - -FORECAST_MODE = ["hourly", "daily"] +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} ATTRIBUTION = "Instituto Português do Mar e Atmosfera" diff --git a/homeassistant/components/ipma/entity.py b/homeassistant/components/ipma/entity.py index bc8136b6206..7eb8e2fe1a7 100644 --- a/homeassistant/components/ipma/entity.py +++ b/homeassistant/components/ipma/entity.py @@ -1,8 +1,11 @@ """Base Entity for IPMA.""" from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity +from pyipma.api import IPMA_API +from pyipma.location import Location + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -10,17 +13,20 @@ from .const import DOMAIN class IPMADevice(Entity): """Common IPMA Device Information.""" - def __init__(self, location) -> None: + _attr_has_entity_name = True + + def __init__(self, api: IPMA_API, location: Location) -> None: """Initialize device information.""" + self._api = api self._location = location self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={ ( DOMAIN, - f"{self._location.station_latitude}, {self._location.station_longitude}", + f"{location.station_latitude}, {location.station_longitude}", ) }, manufacturer=DOMAIN, - name=self._location.name, + name=location.name, ) diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 4f86295db08..4fea047e834 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -1,7 +1,7 @@ { "domain": "ipma", "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)", - "codeowners": ["@dgomes", "@abmantis"], + "codeowners": ["@dgomes"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", "iot_class": "cloud_polling", diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index f02f8b7d9d0..7f5782f3f89 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -1,11 +1,11 @@ """Support for IPMA sensors.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -import async_timeout from pyipma.api import IPMA_API from pyipma.location import Location @@ -75,15 +75,14 @@ class IPMASensor(SensorEntity, IPMADevice): description: IPMASensorEntityDescription, ) -> None: """Initialize the IPMA Sensor.""" - IPMADevice.__init__(self, location) + IPMADevice.__init__(self, api, location) self.entity_description = description - self._api = api self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self.entity_description.key}" @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Update Fire risk.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): self._attr_native_value = await self.entity_description.value_fn( self._location, self._api ) diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index b9f50c66f9e..b9b672e77d9 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -12,7 +12,12 @@ } } }, - "error": { "name_exists": "Name already exists" } + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + } }, "system_health": { "info": { diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 811eddf91bf..a5bb3981575 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -1,11 +1,13 @@ """Support for IPMA weather service.""" from __future__ import annotations +import asyncio +import contextlib import logging +from typing import Literal -import async_timeout from pyipma.api import IPMA_API -from pyipma.forecast import Forecast +from pyipma.forecast import Forecast as IPMAForecast from pyipma.location import Location from homeassistant.components.weather import ( @@ -16,25 +18,25 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_MODE, - CONF_NAME, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DATA_API, DATA_LOCATION, DOMAIN, @@ -53,67 +55,58 @@ async def async_setup_entry( """Add a weather entity from a config_entry.""" api = hass.data[DOMAIN][config_entry.entry_id][DATA_API] location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION] - mode = config_entry.data[CONF_MODE] - - # Migrate old unique_id - @callback - def _async_migrator(entity_entry: er.RegistryEntry): - # Reject if new unique_id - if entity_entry.unique_id.count(",") == 2: - return None - - new_unique_id = ( - f"{location.station_latitude}, {location.station_longitude}, {mode}" - ) - - _LOGGER.info( - "Migrating unique_id from [%s] to [%s]", - entity_entry.unique_id, - new_unique_id, - ) - return {"new_unique_id": new_unique_id} - - await er.async_migrate_entries(hass, config_entry.entry_id, _async_migrator) - - async_add_entities([IPMAWeather(location, api, config_entry.data)], True) + async_add_entities([IPMAWeather(api, location, config_entry)], True) class IPMAWeather(WeatherEntity, IPMADevice): """Representation of a weather condition.""" + _attr_attribution = ATTRIBUTION + _attr_name = None _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) - _attr_attribution = ATTRIBUTION - - def __init__(self, location: Location, api: IPMA_API, config) -> None: + def __init__( + self, api: IPMA_API, location: Location, config_entry: ConfigEntry + ) -> None: """Initialise the platform with a data instance and station name.""" - IPMADevice.__init__(self, location) - self._api = api - self._attr_name = config.get(CONF_NAME, location.name) - self._mode = config.get(CONF_MODE) - self._period = 1 if config.get(CONF_MODE) == "hourly" else 24 + IPMADevice.__init__(self, api, location) + self._mode = config_entry.data.get(CONF_MODE) + self._period = 1 if config_entry.data.get(CONF_MODE) == "hourly" else 24 self._observation = None - self._forecast: list[Forecast] = [] - self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}" + self._daily_forecast: list[IPMAForecast] | None = None + self._hourly_forecast: list[IPMAForecast] | None = None + if self._mode is not None: + self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}" + else: + self._attr_unique_id = ( + f"{self._location.station_latitude}, {self._location.station_longitude}" + ) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Update Condition and Forecast.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): new_observation = await self._location.observation(self._api) - new_forecast = await self._location.forecast(self._api, self._period) if new_observation: self._observation = new_observation else: _LOGGER.warning("Could not update weather observation") - if new_forecast: - self._forecast = new_forecast + if self._period == 24 or self._forecast_listeners["daily"]: + await self._update_forecast("daily", 24, True) else: - _LOGGER.warning("Could not update weather forecast") + self._daily_forecast = None + + if self._period == 1 or self._forecast_listeners["hourly"]: + await self._update_forecast("hourly", 1, True) + else: + self._hourly_forecast = None _LOGGER.debug( "Updated location %s based on %s, current observation %s", @@ -122,23 +115,37 @@ class IPMAWeather(WeatherEntity, IPMADevice): self._observation, ) + async def _update_forecast( + self, + forecast_type: Literal["daily", "hourly"], + period: int, + update_listeners: bool, + ) -> None: + """Update weather forecast.""" + new_forecast = await self._location.forecast(self._api, period) + if new_forecast: + setattr(self, f"_{forecast_type}_forecast", new_forecast) + if update_listeners: + await self.async_update_listeners((forecast_type,)) + else: + _LOGGER.warning("Could not update %s weather forecast", forecast_type) + def _condition_conversion(self, identifier, forecast_dt): """Convert from IPMA weather_type id to HA.""" if identifier == 1 and not is_up(self.hass, forecast_dt): identifier = -identifier - return next( - (k for k, v in CONDITION_CLASSES.items() if identifier in v), - None, - ) + return CONDITION_MAP.get(identifier) @property def condition(self): """Return the current condition.""" - if not self._forecast: + forecast = self._hourly_forecast or self._daily_forecast + + if not forecast: return - return self._condition_conversion(self._forecast[0].weather_type.id, None) + return self._condition_conversion(forecast[0].weather_type.id, None) @property def native_temperature(self): @@ -180,10 +187,9 @@ class IPMAWeather(WeatherEntity, IPMADevice): return self._observation.wind_direction - @property - def forecast(self): + def _forecast(self, forecast: list[IPMAForecast] | None) -> list[Forecast]: """Return the forecast array.""" - if not self._forecast: + if not forecast: return [] return [ @@ -198,5 +204,32 @@ class IPMAWeather(WeatherEntity, IPMADevice): ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, } - for data_in in self._forecast + for data_in in forecast ] + + @property + def forecast(self) -> list[Forecast]: + """Return the forecast array.""" + return self._forecast( + self._hourly_forecast if self._period == 1 else self._daily_forecast + ) + + async def _try_update_forecast( + self, + forecast_type: Literal["daily", "hourly"], + period: int, + ) -> None: + """Try to update weather forecast.""" + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(10): + await self._update_forecast(forecast_type, period, False) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + await self._try_update_forecast("daily", 24) + return self._forecast(self._daily_forecast) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast in native units.""" + await self._try_update_forecast("hourly", 1) + return self._forecast(self._hourly_forecast) diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 9df377b939a..98870c44f5a 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -19,6 +19,10 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IPP from a config entry.""" + # config flow sets this to either UUID, serial number or None + if (device_id := entry.unique_id) is None: + device_id = entry.entry_id + coordinator = IPPDataUpdateCoordinator( hass, host=entry.data[CONF_HOST], @@ -26,6 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: base_path=entry.data[CONF_BASE_PATH], tls=entry.data[CONF_SSL], verify_ssl=entry.data[CONF_VERIFY_SSL], + device_id=device_id, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index a00190eebce..8d1da6eca91 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -59,9 +59,9 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Set up the instance.""" - self.discovery_info = {} + self.discovery_info: dict[str, Any] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/ipp/coordinator.py b/homeassistant/components/ipp/coordinator.py index abc97dd3dd2..8eb8c972fab 100644 --- a/homeassistant/components/ipp/coordinator.py +++ b/homeassistant/components/ipp/coordinator.py @@ -29,8 +29,10 @@ class IPPDataUpdateCoordinator(DataUpdateCoordinator[IPPPrinter]): base_path: str, tls: bool, verify_ssl: bool, + device_id: str, ) -> None: """Initialize global IPP data updater.""" + self.device_id = device_id self.ipp = IPP( host=host, port=port, diff --git a/homeassistant/components/ipp/entity.py b/homeassistant/components/ipp/entity.py index 50f81f74bdb..05adf711fd9 100644 --- a/homeassistant/components/ipp/entity.py +++ b/homeassistant/components/ipp/entity.py @@ -1,7 +1,8 @@ """Entities for The Internet Printing Protocol (IPP) integration.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -11,32 +12,21 @@ from .coordinator import IPPDataUpdateCoordinator class IPPEntity(CoordinatorEntity[IPPDataUpdateCoordinator]): """Defines a base IPP entity.""" + _attr_has_entity_name = True + def __init__( self, - *, - entry_id: str, - device_id: str, coordinator: IPPDataUpdateCoordinator, - name: str, - icon: str, - enabled_default: bool = True, + description: EntityDescription, ) -> None: """Initialize the IPP entity.""" super().__init__(coordinator) - self._device_id = device_id - self._entry_id = entry_id - self._attr_name = name - self._attr_icon = icon - self._attr_entity_registry_enabled_default = enabled_default - @property - def device_info(self) -> DeviceInfo | None: - """Return device information about this IPP device.""" - if self._device_id is None: - return None + self.entity_description = description - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, manufacturer=self.coordinator.data.info.manufacturer, model=self.coordinator.data.info.model, name=self.coordinator.data.info.name, diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index e8bd4425ef3..cedf0521f95 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.14.3"], + "requirements": ["pyipp==0.14.4"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 5058f6d10a8..3bc7035e26b 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -1,14 +1,23 @@ """Support for IPP sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from pyipp import Marker, Printer + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LOCATION, PERCENTAGE +from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from .const import ( @@ -27,6 +36,65 @@ from .coordinator import IPPDataUpdateCoordinator from .entity import IPPEntity +@dataclass +class IPPSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Printer], StateType | datetime] + + +@dataclass +class IPPSensorEntityDescription( + SensorEntityDescription, IPPSensorEntityDescriptionMixin +): + """Describes IPP sensor entity.""" + + attributes_fn: Callable[[Printer], dict[Any, StateType]] = lambda _: {} + + +def _get_marker_attributes_fn( + marker_index: int, attributes_fn: Callable[[Marker], dict[Any, StateType]] +) -> Callable[[Printer], dict[Any, StateType]]: + return lambda printer: attributes_fn(printer.markers[marker_index]) + + +def _get_marker_value_fn( + marker_index: int, value_fn: Callable[[Marker], StateType | datetime] +) -> Callable[[Printer], StateType | datetime]: + return lambda printer: value_fn(printer.markers[marker_index]) + + +PRINTER_SENSORS: tuple[IPPSensorEntityDescription, ...] = ( + IPPSensorEntityDescription( + key="printer", + name=None, + translation_key="printer", + icon="mdi:printer", + device_class=SensorDeviceClass.ENUM, + options=["idle", "printing", "stopped"], + attributes_fn=lambda printer: { + ATTR_INFO: printer.info.printer_info, + ATTR_SERIAL: printer.info.serial, + ATTR_LOCATION: printer.info.location, + ATTR_STATE_MESSAGE: printer.state.message, + ATTR_STATE_REASON: printer.state.reasons, + ATTR_COMMAND_SET: printer.info.command_set, + ATTR_URI_SUPPORTED: ",".join(printer.info.printer_uri_supported), + }, + value_fn=lambda printer: printer.state.printer_state, + ), + IPPSensorEntityDescription( + key="uptime", + translation_key="uptime", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda printer: (utcnow() - timedelta(seconds=printer.info.uptime)), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -34,19 +102,34 @@ async def async_setup_entry( ) -> None: """Set up IPP sensor based on a config entry.""" coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + sensors: list[SensorEntity] = [ + IPPSensor( + coordinator, + description, + ) + for description in PRINTER_SENSORS + ] - # config flow sets this to either UUID, serial number or None - if (unique_id := entry.unique_id) is None: - unique_id = entry.entry_id - - sensors: list[SensorEntity] = [] - - sensors.append(IPPPrinterSensor(entry.entry_id, unique_id, coordinator)) - sensors.append(IPPUptimeSensor(entry.entry_id, unique_id, coordinator)) - - for marker_index in range(len(coordinator.data.markers)): + for index, marker in enumerate(coordinator.data.markers): sensors.append( - IPPMarkerSensor(entry.entry_id, unique_id, coordinator, marker_index) + IPPSensor( + coordinator, + IPPSensorEntityDescription( + key=f"marker_{index}", + name=marker.name, + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + attributes_fn=_get_marker_attributes_fn( + index, + lambda marker: { + ATTR_MARKER_HIGH_LEVEL: marker.high_level, + ATTR_MARKER_LOW_LEVEL: marker.low_level, + ATTR_MARKER_TYPE: marker.marker_type, + }, + ), + value_fn=_get_marker_value_fn(index, lambda marker: marker.level), + ), + ) ) async_add_entities(sensors, True) @@ -55,146 +138,14 @@ async def async_setup_entry( class IPPSensor(IPPEntity, SensorEntity): """Defines an IPP sensor.""" - def __init__( - self, - *, - coordinator: IPPDataUpdateCoordinator, - enabled_default: bool = True, - entry_id: str, - unique_id: str, - icon: str, - key: str, - name: str, - unit_of_measurement: str | None = None, - translation_key: str | None = None, - ) -> None: - """Initialize IPP sensor.""" - self._key = key - self._attr_unique_id = f"{unique_id}_{key}" - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_translation_key = translation_key - - super().__init__( - entry_id=entry_id, - device_id=unique_id, - coordinator=coordinator, - name=name, - icon=icon, - enabled_default=enabled_default, - ) - - -class IPPMarkerSensor(IPPSensor): - """Defines an IPP marker sensor.""" - - def __init__( - self, - entry_id: str, - unique_id: str, - coordinator: IPPDataUpdateCoordinator, - marker_index: int, - ) -> None: - """Initialize IPP marker sensor.""" - self.marker_index = marker_index - - super().__init__( - coordinator=coordinator, - entry_id=entry_id, - unique_id=unique_id, - icon="mdi:water", - key=f"marker_{marker_index}", - name=( - f"{coordinator.data.info.name} {coordinator.data.markers[marker_index].name}" - ), - unit_of_measurement=PERCENTAGE, - ) + entity_description: IPPSensorEntityDescription @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the entity.""" - return { - ATTR_MARKER_HIGH_LEVEL: self.coordinator.data.markers[ - self.marker_index - ].high_level, - ATTR_MARKER_LOW_LEVEL: self.coordinator.data.markers[ - self.marker_index - ].low_level, - ATTR_MARKER_TYPE: self.coordinator.data.markers[ - self.marker_index - ].marker_type, - } + return self.entity_description.attributes_fn(self.coordinator.data) @property - def native_value(self) -> int | None: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" - level = self.coordinator.data.markers[self.marker_index].level - - if level >= 0: - return level - - return None - - -class IPPPrinterSensor(IPPSensor): - """Defines an IPP printer sensor.""" - - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = ["idle", "printing", "stopped"] - - def __init__( - self, entry_id: str, unique_id: str, coordinator: IPPDataUpdateCoordinator - ) -> None: - """Initialize IPP printer sensor.""" - super().__init__( - coordinator=coordinator, - entry_id=entry_id, - unique_id=unique_id, - icon="mdi:printer", - key="printer", - name=coordinator.data.info.name, - unit_of_measurement=None, - translation_key="printer", - ) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - return { - ATTR_INFO: self.coordinator.data.info.printer_info, - ATTR_SERIAL: self.coordinator.data.info.serial, - ATTR_LOCATION: self.coordinator.data.info.location, - ATTR_STATE_MESSAGE: self.coordinator.data.state.message, - ATTR_STATE_REASON: self.coordinator.data.state.reasons, - ATTR_COMMAND_SET: self.coordinator.data.info.command_set, - ATTR_URI_SUPPORTED: self.coordinator.data.info.printer_uri_supported, - } - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - return self.coordinator.data.state.printer_state - - -class IPPUptimeSensor(IPPSensor): - """Defines a IPP uptime sensor.""" - - _attr_device_class = SensorDeviceClass.TIMESTAMP - - def __init__( - self, entry_id: str, unique_id: str, coordinator: IPPDataUpdateCoordinator - ) -> None: - """Initialize IPP uptime sensor.""" - super().__init__( - coordinator=coordinator, - enabled_default=False, - entry_id=entry_id, - unique_id=unique_id, - icon="mdi:clock-outline", - key="uptime", - name=f"{coordinator.data.info.name} Uptime", - ) - - @property - def native_value(self) -> datetime: - """Return the state of the sensor.""" - return utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index f3ea929c9ec..ac879ef0ab3 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -40,6 +40,9 @@ "idle": "[%key:common::state::idle%]", "stopped": "Stopped" } + }, + "uptime": { + "name": "Uptime" } } } diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 2552be7717a..ee3c5d9071d 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -8,8 +8,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index 32516ee99c9..d7b7083cdef 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -8,8 +8,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e6e23fdf837..c611bf83050 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -5,7 +5,6 @@ import asyncio from urllib.parse import urlparse from aiohttp import CookieJar -import async_timeout from pyisy import ISY, ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy.constants import CONFIG_NETWORKING, CONFIG_PORTAL import voluptuous as vol @@ -23,8 +22,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from .const import ( _LOGGER, @@ -102,7 +100,7 @@ async def async_setup_entry( ) try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): await isy.initialize() except asyncio.TimeoutError as err: raise ConfigEntryNotReady( diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 621b17f096e..69db4afd1be 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity @@ -44,6 +44,7 @@ from .const import ( TYPE_INSTEON_MOTION, ) from .entity import ISYNodeEntity, ISYProgramEntity +from .models import IsyData DEVICE_PARENT_REQUIRED = [ BinarySensorDeviceClass.OPENING, @@ -79,7 +80,7 @@ async def async_setup_entry( | ISYBinarySensorProgramEntity ) - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] devices: dict[str, DeviceInfo] = isy_data.devices for node in isy_data.nodes[Platform.BINARY_SENSOR]: assert isinstance(node, Node) diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py index 3eba58aa0aa..6e00e1934f2 100644 --- a/homeassistant/components/isy994/button.py +++ b/homeassistant/components/isy994/button.py @@ -17,10 +17,11 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_NETWORK, DOMAIN +from .models import IsyData async def async_setup_entry( @@ -29,7 +30,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ISY/IoX button from config entry.""" - isy_data = hass.data[DOMAIN][config_entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] isy: ISY = isy_data.root device_info = isy_data.devices entities: list[ diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 8fc90efaabc..4ddbbd86060 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum @@ -56,6 +56,7 @@ from .const import ( ) from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass +from .models import IsyData async def async_setup_entry( @@ -64,7 +65,7 @@ async def async_setup_entry( """Set up the ISY thermostat platform.""" entities = [] - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] devices: dict[str, DeviceInfo] = isy_data.devices for node in isy_data.nodes[Platform.CLIMATE]: entities.append(ISYThermostatEntity(node, devices.get(node.primary_node))) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index d6bbf236c13..9f16b4a0d0c 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,13 +1,13 @@ """Config flow for Universal Devices ISY/IoX integration.""" from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse, urlunparse from aiohttp import CookieJar -import async_timeout from pyisy import ISYConnectionError, ISYInvalidAuthError, ISYResponseParseError from pyisy.configuration import Configuration from pyisy.connection import Connection @@ -97,7 +97,7 @@ async def validate_input( ) try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): isy_conf_xml = await isy_conn.test_connection() except ISYInvalidAuthError as error: raise InvalidAuth from error diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index 4504cde713e..2ada6339295 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -13,18 +13,19 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE from .entity import ISYNodeEntity, ISYProgramEntity +from .models import IsyData async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY cover platform.""" - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] entities: list[ISYCoverEntity | ISYCoverProgramEntity] = [] devices: dict[str, DeviceInfo] = isy_data.devices for node in isy_data.nodes[Platform.COVER]: diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 425f1fe5b87..80319b83ba2 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -23,7 +23,8 @@ from pyisy.variables import Variable from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from .const import DOMAIN diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index 75c033bd9ea..e451ef882b4 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -10,7 +10,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, @@ -20,6 +20,7 @@ from homeassistant.util.percentage import ( from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity +from .models import IsyData SPEED_RANGE = (1, 255) # off is not included @@ -28,7 +29,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY fan platform.""" - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] devices: dict[str, DeviceInfo] = isy_data.devices entities: list[ISYFanEntity | ISYFanProgramEntity] = [] diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 611d0467710..5e0ff592ea9 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -24,7 +24,7 @@ from pyisy.nodes import Group, Node, Nodes from pyisy.programs import Programs from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import ( _LOGGER, diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index 8c64e5b9d55..b16b4ca5a83 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -11,12 +11,13 @@ from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE from .entity import ISYNodeEntity +from .models import IsyData ATTR_LAST_BRIGHTNESS = "last_brightness" @@ -25,7 +26,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY light platform.""" - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] devices: dict[str, DeviceInfo] = isy_data.devices isy_options = entry.options restore_light_state = isy_options.get(CONF_RESTORE_LIGHT_STATE, False) diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index 9bf487def07..67c2587a238 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import ( from .const import DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity +from .models import IsyData from .services import ( SERVICE_DELETE_USER_CODE_SCHEMA, SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, @@ -49,7 +50,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY lock platform.""" - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] devices: dict[str, DeviceInfo] = isy_data.devices entities: list[ISYLockEntity | ISYLockProgramEntity] = [] for node in isy_data.nodes[Platform.LOCK]: diff --git a/homeassistant/components/isy994/models.py b/homeassistant/components/isy994/models.py index 202bebb32f8..c8a7e1dbefe 100644 --- a/homeassistant/components/isy994/models.py +++ b/homeassistant/components/isy994/models.py @@ -12,7 +12,7 @@ from pyisy.programs import Program from pyisy.variables import Variable from homeassistant.const import Platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import ( CONF_NETWORK, diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index e8defd4592c..baadf3b2dc7 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -51,6 +51,7 @@ from .const import ( ) from .entity import ISYAuxControlEntity from .helpers import convert_isy_value_to_hass +from .models import IsyData ISY_MAX_SIZE = (2**32) / 2 ON_RANGE = (1, 255) # Off is not included @@ -81,7 +82,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up ISY/IoX number entities from config entry.""" - isy_data = hass.data[DOMAIN][config_entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] device_info = isy_data.devices entities: list[ ISYVariableNumberEntity | ISYAuxControlNumberEntity | ISYBacklightNumberEntity diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 60e2111848d..3c55e5cbda9 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 5f36fed6b6a..b1899100dd4 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -31,7 +31,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -45,6 +45,7 @@ from .const import ( ) from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass +from .models import IsyData # Disable general purpose and redundant sensors by default AUX_DISABLED_BY_DEFAULT_MATCH = ["GV", "DO"] @@ -109,7 +110,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the ISY sensor platform.""" - isy_data = hass.data[DOMAIN][entry.entry_id] + isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] entities: list[ISYSensorEntity] = [] devices: dict[str, DeviceInfo] = isy_data.devices diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 62ae375736d..39b84faad30 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -23,7 +23,7 @@ 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.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 4c9eb3a607c..2dcdd72f6b9 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -30,8 +30,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType @@ -133,6 +133,8 @@ class ControllerDevice(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_should_poll = False _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_name = None def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" @@ -169,7 +171,7 @@ class ControllerDevice(ClimateEntity): identifiers={(IZONE, self.unique_id)}, manufacturer="IZone", model=self._controller.sys_type, - name=self.name, + name=f"iZone Controller {self._controller.device_uid}", ) # Create the zones @@ -256,11 +258,6 @@ class ControllerDevice(ClimateEntity): """Return the ID of the controller device.""" return self._controller.device_uid - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"iZone Controller {self._controller.device_uid}" - @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" @@ -444,13 +441,14 @@ class ZoneDevice(ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, controller: ControllerDevice, zone: Zone) -> None: """Initialise ZoneDevice.""" self._controller = controller self._zone = zone - self._name = zone.name.title() if zone.type != Zone.Type.AUTO: self._state_to_pizone = { @@ -471,7 +469,7 @@ class ZoneDevice(ClimateEntity): }, manufacturer="IZone", model=zone.type.name.title(), - name=self.name, + name=zone.name.title(), via_device=(IZONE, controller.unique_id), ) @@ -500,7 +498,6 @@ class ZoneDevice(ClimateEntity): return if not self.available: return - self._name = zone.name.title() self.async_write_ha_state() self.async_on_remove( @@ -517,11 +514,6 @@ class ZoneDevice(ClimateEntity): """Return the ID of the controller device.""" return f"{self._controller.unique_id}_z{self._zone.index + 1}" - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - @property @_return_on_connection_error(0) def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index af5205feb07..8e6fe584456 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -4,8 +4,6 @@ import asyncio from contextlib import suppress import logging -from async_timeout import timeout - from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_entry_flow from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -28,7 +26,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: disco = await async_start_discovery_service(hass) with suppress(asyncio.TimeoutError): - async with timeout(TIMEOUT_DISCOVERY): + async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() if not disco.pi_disco.controllers: diff --git a/homeassistant/components/jellyfin/entity.py b/homeassistant/components/jellyfin/entity.py index eb74b5d5c51..e45557fa4b6 100644 --- a/homeassistant/components/jellyfin/entity.py +++ b/homeassistant/components/jellyfin/entity.py @@ -1,8 +1,8 @@ """Base Entity for Jellyfin.""" from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index bcd8e975823..76343818702 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -13,7 +13,7 @@ from homeassistant.components.media_player import ( from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import parse_datetime diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 483782c948a..c1744b30b1a 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -87,13 +87,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=timedelta(seconds=30), ) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = { JUICENET_API: juicenet, JUICENET_COORDINATOR: coordinator, } - await coordinator.async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 2f25a934e7f..3c325715c82 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -2,7 +2,7 @@ from pyjuicenet import Charger -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/justnimbus/entity.py b/homeassistant/components/justnimbus/entity.py index 67575809135..7303d4ec2c7 100644 --- a/homeassistant/components/justnimbus/entity.py +++ b/homeassistant/components/justnimbus/entity.py @@ -1,7 +1,7 @@ """Base Entity for JustNimbus sensors.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -13,6 +13,8 @@ class JustNimbusEntity( ): """Defines a base JustNimbus entity.""" + _attr_has_entity_name = True + def __init__( self, *, diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index e3d6562c088..156fa37e982 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -46,7 +46,7 @@ class JustNimbusEntityDescription( SENSOR_TYPES = ( JustNimbusEntityDescription( key="pump_flow", - name="Pump flow", + translation_key="pump_flow", icon="mdi:pump", native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, @@ -55,7 +55,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="drink_flow", - name="Drink flow", + translation_key="drink_flow", icon="mdi:water-pump", native_unit_of_measurement=VOLUME_FLOW_RATE_LITERS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, @@ -64,7 +64,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="pump_pressure", - name="Pump pressure", + translation_key="pump_pressure", native_unit_of_measurement=UnitOfPressure.BAR, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -73,7 +73,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="pump_starts", - name="Pump starts", + translation_key="pump_starts", icon="mdi:restart", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -81,7 +81,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="pump_hours", - name="Pump hours", + translation_key="pump_hours", icon="mdi:clock", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, @@ -91,7 +91,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="reservoir_temp", - name="Reservoir Temperature", + translation_key="reservoir_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -100,7 +100,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="reservoir_content", - name="Reservoir content", + translation_key="reservoir_content", icon="mdi:car-coolant-level", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -110,7 +110,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="total_saved", - name="Total saved", + translation_key="total_saved", icon="mdi:water-opacity", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -120,7 +120,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="total_replenished", - name="Total replenished", + translation_key="total_replenished", icon="mdi:water", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -130,7 +130,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="error_code", - name="Error code", + translation_key="error_code", icon="mdi:bug", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -138,7 +138,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="totver", - name="Total use", + translation_key="total_use", icon="mdi:chart-donut", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, @@ -148,7 +148,7 @@ SENSOR_TYPES = ( ), JustNimbusEntityDescription( key="reservoir_content_max", - name="Max reservoir content", + translation_key="reservoir_content_max", icon="mdi:waves", native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.VOLUME, diff --git a/homeassistant/components/justnimbus/strings.json b/homeassistant/components/justnimbus/strings.json index 609b1425e93..92ebf19714a 100644 --- a/homeassistant/components/justnimbus/strings.json +++ b/homeassistant/components/justnimbus/strings.json @@ -15,5 +15,45 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "pump_flow": { + "name": "Pump flow" + }, + "drink_flow": { + "name": "Drink flow" + }, + "pump_pressure": { + "name": "Pump pressure" + }, + "pump_starts": { + "name": "Pump starts" + }, + "pump_hours": { + "name": "Pump hours" + }, + "reservoir_temperature": { + "name": "Reservoir temperature" + }, + "reservoir_content": { + "name": "Reservoir content" + }, + "total_saved": { + "name": "Total saved" + }, + "total_replenished": { + "name": "Total replenished" + }, + "error_code": { + "name": "Error code" + }, + "total_use": { + "name": "Total use" + }, + "reservoir_content_max": { + "name": "Maximum reservoir content" + } + } } } diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py index 5d1821c6b56..a88fba03cb0 100644 --- a/homeassistant/components/jvc_projector/entity.py +++ b/homeassistant/components/jvc_projector/entity.py @@ -6,7 +6,7 @@ import logging from jvcprojector import JvcProjector -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NAME diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py index 980c01d02a1..09d470af1de 100644 --- a/homeassistant/components/kaiterra/api_data.py +++ b/homeassistant/components/kaiterra/api_data.py @@ -3,7 +3,6 @@ import asyncio from logging import getLogger from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError -import async_timeout from kaiterra_async_client import AQIStandard, KaiterraAPIClient, Units from homeassistant.const import CONF_API_KEY, CONF_DEVICE_ID, CONF_DEVICES, CONF_TYPE @@ -53,7 +52,7 @@ class KaiterraApiData: """Get the data from Kaiterra API.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await self._api.get_latest_sensor_readings(self._devices) except (ClientResponseError, ClientConnectorError, asyncio.TimeoutError) as err: _LOGGER.debug("Couldn't fetch data from Kaiterra API: %s", err) diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index 87a9fa4da0e..667cba757d6 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -6,7 +6,8 @@ import logging from typing import TYPE_CHECKING from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN as KALEIDESCAPE_DOMAIN, NAME as KALEIDESCAPE_NAME diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index fdab10ea55e..77101dcbf3e 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -18,8 +18,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_call_later import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py index f4e7f9e0424..d129505515d 100644 --- a/homeassistant/components/keyboard/__init__.py +++ b/homeassistant/components/keyboard/__init__.py @@ -1,5 +1,5 @@ """Support to emulate keyboard presses on host machine.""" -from pykeyboard import PyKeyboard # pylint: disable=import-error +from pykeyboard import PyKeyboard import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index df3b6f0e427..eecde05d1f4 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -1,5 +1,4 @@ """Receive signals from a keyboard and use it as a remote control.""" -# pylint: disable=import-error from __future__ import annotations import asyncio @@ -331,7 +330,6 @@ class KeyboardRemote: _LOGGER.debug("Start device monitoring") await self.hass.async_add_executor_job(self.dev.grab) async for event in self.dev.async_read_loop(): - # pylint: disable=no-member if event.type is ecodes.EV_KEY: if event.value in self.key_values: _LOGGER.debug( diff --git a/homeassistant/components/keymitt_ble/entity.py b/homeassistant/components/keymitt_ble/entity.py index 31315e59efb..a9294bce239 100644 --- a/homeassistant/components/keymitt_ble/entity.py +++ b/homeassistant/components/keymitt_ble/entity.py @@ -7,7 +7,7 @@ from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, ) from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import MANUFACTURER from .coordinator import MicroBotDataUpdateCoordinator @@ -16,11 +16,12 @@ from .coordinator import MicroBotDataUpdateCoordinator class MicroBotEntity(PassiveBluetoothCoordinatorEntity[MicroBotDataUpdateCoordinator]): """Generic entity for all MicroBots.""" + _attr_has_entity_name = True + def __init__(self, coordinator, config_entry): """Initialise the entity.""" super().__init__(coordinator) self._address = self.coordinator.ble_device.address - self._attr_name = "Push" self._attr_unique_id = self._address self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_BLUETOOTH, self._address)}, diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json index ab2d4ad9440..2a1f428603e 100644 --- a/homeassistant/components/keymitt_ble/strings.json +++ b/homeassistant/components/keymitt_ble/strings.json @@ -24,6 +24,13 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, + "entity": { + "switch": { + "push": { + "name": "Push" + } + } + }, "services": { "calibrate": { "name": "Calibrate", diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index 3e5883ae5d0..4c9f0c335a7 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -43,7 +43,7 @@ async def async_setup_entry( class MicroBotBinarySwitch(MicroBotEntity, SwitchEntity): """MicroBot switch class.""" - _attr_has_entity_name = True + _attr_translation_key = "push" async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index a85221108f8..5c8088823b2 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -27,9 +27,10 @@ DOMAIN = "kitchen_sink" COMPONENTS_WITH_DEMO_PLATFORM = [ - Platform.SENSOR, - Platform.LOCK, Platform.IMAGE, + Platform.LAWN_MOWER, + Platform.LOCK, + Platform.SENSOR, Platform.WEATHER, ] diff --git a/homeassistant/components/kitchen_sink/lawn_mower.py b/homeassistant/components/kitchen_sink/lawn_mower.py new file mode 100644 index 00000000000..119b37b7569 --- /dev/null +++ b/homeassistant/components/kitchen_sink/lawn_mower.py @@ -0,0 +1,100 @@ +"""Demo platform that has a couple fake lawn mowers.""" +from __future__ import annotations + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +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 + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo lawn mowers.""" + async_add_entities( + [ + DemoLawnMower( + "kitchen_sink_mower_001", + "Mower can mow", + LawnMowerActivity.DOCKED, + LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_002", + "Mower can dock", + LawnMowerActivity.MOWING, + LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_003", + "Mower can pause", + LawnMowerActivity.DOCKED, + LawnMowerEntityFeature.PAUSE | LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_004", + "Mower can do all", + LawnMowerActivity.DOCKED, + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_005", + "Mower is paused", + LawnMowerActivity.PAUSED, + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Everything but the Kitchen Sink config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoLawnMower(LawnMowerEntity): + """Representation of a Demo lawn mower.""" + + def __init__( + self, + unique_id: str, + name: str, + activity: LawnMowerActivity, + features: LawnMowerEntityFeature = LawnMowerEntityFeature(0), + ) -> None: + """Initialize the lawn mower.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_activity = activity + + async def async_start_mowing(self) -> None: + """Start mowing.""" + self._attr_activity = LawnMowerActivity.MOWING + self.async_write_ha_state() + + async def async_dock(self) -> None: + """Start docking.""" + self._attr_activity = LawnMowerActivity.DOCKED + self.async_write_ha_state() + + async def async_pause(self) -> None: + """Pause mower.""" + self._attr_activity = LawnMowerActivity.PAUSED + self.async_write_ha_state() diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 6912c940482..4e1e3bd2010 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py index aba30013746..8449b68b460 100644 --- a/homeassistant/components/kitchen_sink/weather.py +++ b/homeassistant/components/kitchen_sink/weather.py @@ -45,6 +45,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} async def async_setup_entry( @@ -352,9 +357,7 @@ class DemoWeather(WeatherEntity): @property def condition(self) -> str: """Return the weather condition.""" - return [ - k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v - ][0] + return CONDITION_MAP[self._condition.lower()] @property def forecast(self) -> list[Forecast]: diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py index ef4e8ebb303..638884dff26 100644 --- a/homeassistant/components/kmtronic/__init__.py +++ b/homeassistant/components/kmtronic/__init__.py @@ -1,9 +1,9 @@ """The kmtronic integration.""" +import asyncio from datetime import timedelta import logging import aiohttp -import async_timeout from pykmtronic.auth import Auth from pykmtronic.hub import KMTronicHubAPI @@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data(): try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hub.async_update_relays() except aiohttp.client_exceptions.ClientResponseError as err: raise UpdateFailed(f"Wrong credentials: {err}") from err diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index ed54315de90..cd1b181803f 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -5,7 +5,7 @@ import urllib.parse from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 0a405146d9c..8e5783dc2d1 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -3,8 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import AsyncGenerator -from pathlib import Path -import shutil from typing import Any, Final import voluptuous as vol @@ -18,15 +16,13 @@ from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.io.gateway_scanner import GatewayDescriptor, GatewayScanner from xknx.io.self_description import request_description from xknx.io.util import validate_ip as xknx_validate_ip -from xknx.secure.keyring import Keyring, XMLInterface, sync_load_keyring +from xknx.secure.keyring import Keyring, XMLInterface -from homeassistant.components.file_upload import process_uploaded_file from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback from homeassistant.data_entry_flow import FlowHandler, FlowResult from homeassistant.helpers import selector -from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import UNDEFINED from .const import ( @@ -60,6 +56,7 @@ from .const import ( TELEGRAM_LOG_MAX, KNXConfigEntryData, ) +from .helpers.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file from .schema import ia_validator, ip_v4_validator CONF_KNX_GATEWAY: Final = "gateway" @@ -77,7 +74,6 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData( ) CONF_KEYRING_FILE: Final = "knxkeys_file" -DEFAULT_KNX_KEYRING_FILENAME: Final = "keyring.knxkeys" CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { @@ -499,10 +495,15 @@ class KNXCommonFlow(ABC, FlowHandler): if user_input is not None: password = user_input[CONF_KNX_KNXKEY_PASSWORD] - errors = await self._save_uploaded_knxkeys_file( - uploaded_file_id=user_input[CONF_KEYRING_FILE], - password=password, - ) + try: + self._keyring = await save_uploaded_knxkeys_file( + self.hass, + uploaded_file_id=user_input[CONF_KEYRING_FILE], + password=password, + ) + except InvalidSecureConfiguration: + errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature" + if not errors and self._keyring: self.new_entry_data |= KNXConfigEntryData( knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}", @@ -711,33 +712,6 @@ class KNXCommonFlow(ABC, FlowHandler): step_id="routing", data_schema=vol.Schema(fields), errors=errors ) - async def _save_uploaded_knxkeys_file( - self, uploaded_file_id: str, password: str - ) -> dict[str, str]: - """Validate the uploaded file and move it to the storage directory. Return errors.""" - - def _process_upload() -> tuple[Keyring | None, dict[str, str]]: - keyring: Keyring | None = None - errors = {} - with process_uploaded_file(self.hass, uploaded_file_id) as file_path: - try: - keyring = sync_load_keyring( - path=file_path, - password=password, - ) - except InvalidSecureConfiguration: - errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature" - else: - dest_path = Path(self.hass.config.path(STORAGE_DIR, DOMAIN)) - dest_path.mkdir(exist_ok=True) - dest_file = dest_path / DEFAULT_KNX_KEYRING_FILENAME - shutil.move(file_path, dest_file) - return keyring, errors - - keyring, errors = await self.hass.async_add_executor_job(_process_upload) - self._keyring = keyring - return errors - class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py index 18e6197360a..583ca2f768b 100644 --- a/homeassistant/components/knx/device.py +++ b/homeassistant/components/knx/device.py @@ -8,7 +8,7 @@ from xknx.io.gateway_scanner import GatewayDescriptor from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN diff --git a/homeassistant/components/knx/helpers/keyring.py b/homeassistant/components/knx/helpers/keyring.py new file mode 100644 index 00000000000..5d1dfea6383 --- /dev/null +++ b/homeassistant/components/knx/helpers/keyring.py @@ -0,0 +1,47 @@ +"""KNX Keyring handler.""" +import logging +from pathlib import Path +import shutil +from typing import Final + +from xknx.exceptions.exception import InvalidSecureConfiguration +from xknx.secure.keyring import Keyring, sync_load_keyring + +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR + +from ..const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +DEFAULT_KNX_KEYRING_FILENAME: Final = "keyring.knxkeys" + + +async def save_uploaded_knxkeys_file( + hass: HomeAssistant, uploaded_file_id: str, password: str +) -> Keyring: + """Validate the uploaded file and move it to the storage directory. + + Return a Keyring object. + Raises InvalidSecureConfiguration if the file or password is invalid. + """ + + def _process_upload() -> Keyring: + with process_uploaded_file(hass, uploaded_file_id) as file_path: + try: + keyring = sync_load_keyring( + path=file_path, + password=password, + ) + except InvalidSecureConfiguration as err: + _LOGGER.debug(err) + raise + dest_path = Path(hass.config.path(STORAGE_DIR, DOMAIN)) + dest_path.mkdir(exist_ok=True) + dest_file = dest_path / DEFAULT_KNX_KEYRING_FILENAME + shutil.move(file_path, dest_file) + return keyring + + return await hass.async_add_executor_job(_process_upload) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 07747f094c3..f25e78a4d70 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -4,11 +4,7 @@ from __future__ import annotations from typing import Any, cast from xknx import XKNX -from xknx.devices.light import ( - ColorTemperatureType, - Light as XknxLight, - XYYColor, -) +from xknx.devices.light import ColorTemperatureType, Light as XknxLight, XYYColor from homeassistant import config_entries from homeassistant.components.light import ( diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 4a7f30506b2..9c69abc08c8 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -44,7 +44,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_platform, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import is_internal_request diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 119c7c946a5..fa8a35d7a64 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -197,7 +197,6 @@ DEVICE_SCHEMA_YAML = vol.All( import_device_validator, ) -# pylint: disable=no-value-for-parameter CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index a4ceed5c50d..2f21f8c15bd 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as KONNECTED_DOMAIN diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 749e1d5fd82..b341afa765f 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index c5a0ca712e5..ba0dc62b606 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -13,8 +13,8 @@ from homeassistant.const import ( CONF_ZONE, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index 35ec7bb9456..1c495ac9db9 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_ST from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 885b19faf28..834057d63b8 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 1a89e5617cc..779cc24b0c4 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -9,7 +9,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 036f2baf98e..78ab609aa16 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 4427f4bd4e1..574368b432f 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 1cfade2a6b7..395de951bbd 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -5,7 +5,6 @@ import asyncio from datetime import timedelta import logging -import async_timeout import krakenex import pykrakenapi @@ -73,7 +72,7 @@ class KrakenData: once. """ try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._hass.async_add_executor_job(self._get_kraken_data) except pykrakenapi.pykrakenapi.KrakenAPIError as error: if "Unknown asset pair" in str(error): diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 4bbf232f84b..a6c00e62b62 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -13,8 +13,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -47,93 +47,93 @@ class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysM SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( KrakenSensorEntityDescription( key="ask", - name="Ask", + translation_key="ask", value_fn=lambda x, y: x.data[y]["ask"][0], ), KrakenSensorEntityDescription( key="ask_volume", - name="Ask Volume", + translation_key="ask_volume", value_fn=lambda x, y: x.data[y]["ask"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="bid", - name="Bid", + translation_key="bid", value_fn=lambda x, y: x.data[y]["bid"][0], ), KrakenSensorEntityDescription( key="bid_volume", - name="Bid Volume", + translation_key="bid_volume", value_fn=lambda x, y: x.data[y]["bid"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_today", - name="Volume Today", + translation_key="volume_today", value_fn=lambda x, y: x.data[y]["volume"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_last_24h", - name="Volume last 24h", + translation_key="volume_last_24h", value_fn=lambda x, y: x.data[y]["volume"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_weighted_average_today", - name="Volume weighted average today", + translation_key="volume_weighted_average_today", value_fn=lambda x, y: x.data[y]["volume_weighted_average"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_weighted_average_last_24h", - name="Volume weighted average last 24h", + translation_key="volume_weighted_average_last_24h", value_fn=lambda x, y: x.data[y]["volume_weighted_average"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="number_of_trades_today", - name="Number of trades today", + translation_key="number_of_trades_today", value_fn=lambda x, y: x.data[y]["number_of_trades"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="number_of_trades_last_24h", - name="Number of trades last 24h", + translation_key="number_of_trades_last_24h", value_fn=lambda x, y: x.data[y]["number_of_trades"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="last_trade_closed", - name="Last trade closed", + translation_key="last_trade_closed", value_fn=lambda x, y: x.data[y]["last_trade_closed"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="low_today", - name="Low today", + translation_key="low_today", value_fn=lambda x, y: x.data[y]["low"][0], ), KrakenSensorEntityDescription( key="low_last_24h", - name="Low last 24h", + translation_key="low_last_24h", value_fn=lambda x, y: x.data[y]["low"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="high_today", - name="High today", + translation_key="high_today", value_fn=lambda x, y: x.data[y]["high"][0], ), KrakenSensorEntityDescription( key="high_last_24h", - name="High last 24h", + translation_key="high_last_24h", value_fn=lambda x, y: x.data[y]["high"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="opening_price_today", - name="Opening price today", + translation_key="opening_price_today", value_fn=lambda x, y: x.data[y]["opening_price"], entity_registry_enabled_default=False, ), @@ -207,6 +207,9 @@ class KrakenSensor( entity_description: KrakenSensorEntityDescription + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + def __init__( self, kraken_data: KrakenData, @@ -233,7 +236,6 @@ class KrakenSensor( ).lower() self._received_data_at_least_once = False self._available = True - self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_device_info = DeviceInfo( configuration_url="https://www.kraken.com/", @@ -242,7 +244,6 @@ class KrakenSensor( manufacturer="Kraken.com", name=self._device_name, ) - self._attr_has_entity_name = True async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/kraken/strings.json b/homeassistant/components/kraken/strings.json index e8ad5ffb98c..c636dbf8d1f 100644 --- a/homeassistant/components/kraken/strings.json +++ b/homeassistant/components/kraken/strings.json @@ -18,5 +18,57 @@ } } } + }, + "entity": { + "sensor": { + "ask": { + "name": "Ask" + }, + "ask_volume": { + "name": "Ask volume" + }, + "bid": { + "name": "Bid" + }, + "bid_volume": { + "name": "Bid volume" + }, + "volume_today": { + "name": "Volume today" + }, + "volume_last_24h": { + "name": "Volume last 24h" + }, + "volume_weighted_average_today": { + "name": "Volume weighted average today" + }, + "volume_weighted_average_last_24h": { + "name": "Volume weighted average last 24h" + }, + "number_of_trades_today": { + "name": "Number of trades today" + }, + "number_of_trades_last_24h": { + "name": "Number of trades last 24h" + }, + "last_trade_closed": { + "name": "Last trade closed" + }, + "low_today": { + "name": "Low today" + }, + "low_last_24h": { + "name": "Low last 24h" + }, + "high_today": { + "name": "High today" + }, + "high_last_24h": { + "name": "High last 24h" + }, + "opening_price_today": { + "name": "Opening price today" + } + } } } diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index 91f19dbdd08..c68633ab639 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 547772cad09..76688af61ae 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index 35810df0273..54626a3838d 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -1,8 +1,11 @@ """Base entity for the LaMetric integration.""" from __future__ import annotations -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 0279af2e610..28317238bf9 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -26,10 +26,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = ultraheat_api.HeatMeterService(reader) coordinator = UltraheatCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 0353e5e63c7..4f7966ae90f 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -5,7 +5,6 @@ import asyncio import logging from typing import Any -import async_timeout import serial from serial.tools import list_ports import ultraheat_api @@ -105,7 +104,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): reader = ultraheat_api.UltraheatReader(port) heat_meter = ultraheat_api.HeatMeterService(reader) try: - async with async_timeout.timeout(ULTRAHEAT_TIMEOUT): + async with asyncio.timeout(ULTRAHEAT_TIMEOUT): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index c85c661e79c..27231dc7b92 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -1,8 +1,8 @@ """Data update coordinator for the ultraheat api.""" +import asyncio import logging -import async_timeout import serial from ultraheat_api.response import HeatMeterResponse from ultraheat_api.service import HeatMeterService @@ -31,7 +31,7 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): async def _async_update_data(self) -> HeatMeterResponse: """Fetch data from API endpoint.""" try: - async with async_timeout.timeout(ULTRAHEAT_TIMEOUT): + async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) except (FileNotFoundError, serial.serialutil.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 9669648b4c5..8ef81e899b7 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +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 ( diff --git a/homeassistant/components/lastfm/coordinator.py b/homeassistant/components/lastfm/coordinator.py index 533f9ec3b09..6e62fe2c84e 100644 --- a/homeassistant/components/lastfm/coordinator.py +++ b/homeassistant/components/lastfm/coordinator.py @@ -11,11 +11,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - CONF_USERS, - DOMAIN, - LOGGER, -) +from .const import CONF_USERS, DOMAIN, LOGGER def format_track(track: Track | None) -> str | None: diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 116a0813387..40d6521bdc9 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -11,14 +11,11 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_LAST_PLAYED, @@ -90,6 +87,8 @@ class LastFmSensor(CoordinatorEntity[LastFMDataUpdateCoordinator], SensorEntity) _attr_attribution = "Data provided by Last.fm" _attr_icon = "mdi:radio-fm" + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -101,7 +100,6 @@ class LastFmSensor(CoordinatorEntity[LastFMDataUpdateCoordinator], SensorEntity) super().__init__(coordinator) self._username = username self._attr_unique_id = hashlib.sha256(username.encode("utf-8")).hexdigest() - self._attr_name = username self._attr_device_info = DeviceInfo( configuration_url="https://www.last.fm", entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 3e865bd4c0c..81882b68f00 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 47728a38983..121d2cd913f 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -1,8 +1,8 @@ """Custom DataUpdateCoordinator for the laundrify integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from laundrify_aio import LaundrifyAPI from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException @@ -36,7 +36,7 @@ class LaundrifyUpdateCoordinator(DataUpdateCoordinator[dict[str, LaundrifyDevice try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): return {m["_id"]: m for m in await self.laundrify_api.get_machines()} except UnauthorizedException as err: # Raising ConfigEntryAuthFailed will cancel future updates diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py new file mode 100644 index 00000000000..5388463316f --- /dev/null +++ b/homeassistant/components/lawn_mower/__init__.py @@ -0,0 +1,120 @@ +"""The lawn mower integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import ( + DOMAIN, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + LawnMowerActivity, + LawnMowerEntityFeature, +) + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the lawn_mower component.""" + component = hass.data[DOMAIN] = EntityComponent[LawnMowerEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_START_MOWING, + {}, + "async_start_mowing", + [LawnMowerEntityFeature.START_MOWING], + ) + component.async_register_entity_service( + SERVICE_PAUSE, {}, "async_pause", [LawnMowerEntityFeature.PAUSE] + ) + component.async_register_entity_service( + SERVICE_DOCK, {}, "async_dock", [LawnMowerEntityFeature.DOCK] + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up lawn mower devices.""" + component: EntityComponent[LawnMowerEntity] = 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[LawnMowerEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class LawnMowerEntityEntityDescription(EntityDescription): + """A class that describes lawn mower entities.""" + + +class LawnMowerEntity(Entity): + """Base class for lawn mower entities.""" + + entity_description: LawnMowerEntityEntityDescription + _attr_activity: LawnMowerActivity | None = None + _attr_supported_features: LawnMowerEntityFeature = LawnMowerEntityFeature(0) + + @final + @property + def state(self) -> str | None: + """Return the current state.""" + if (activity := self.activity) is None: + return None + return str(activity) + + @property + def activity(self) -> LawnMowerActivity | None: + """Return the current lawn mower activity.""" + return self._attr_activity + + @property + def supported_features(self) -> LawnMowerEntityFeature: + """Flag lawn mower features that are supported.""" + return self._attr_supported_features + + def start_mowing(self) -> None: + """Start or resume mowing.""" + raise NotImplementedError() + + async def async_start_mowing(self) -> None: + """Start or resume mowing.""" + await self.hass.async_add_executor_job(self.start_mowing) + + def dock(self) -> None: + """Dock the mower.""" + raise NotImplementedError() + + async def async_dock(self) -> None: + """Dock the mower.""" + await self.hass.async_add_executor_job(self.dock) + + def pause(self) -> None: + """Pause the lawn mower.""" + raise NotImplementedError() + + async def async_pause(self) -> None: + """Pause the lawn mower.""" + await self.hass.async_add_executor_job(self.pause) diff --git a/homeassistant/components/lawn_mower/const.py b/homeassistant/components/lawn_mower/const.py new file mode 100644 index 00000000000..706c9616450 --- /dev/null +++ b/homeassistant/components/lawn_mower/const.py @@ -0,0 +1,33 @@ +"""Constants for the lawn mower integration.""" +from enum import IntFlag, StrEnum + + +class LawnMowerActivity(StrEnum): + """Activity state of lawn mower devices.""" + + ERROR = "error" + """Device is in error state, needs assistance.""" + + PAUSED = "paused" + """Paused during activity.""" + + MOWING = "mowing" + """Device is mowing.""" + + DOCKED = "docked" + """Device is docked.""" + + +class LawnMowerEntityFeature(IntFlag): + """Supported features of the lawn mower entity.""" + + START_MOWING = 1 + PAUSE = 2 + DOCK = 4 + + +DOMAIN = "lawn_mower" + +SERVICE_START_MOWING = "start_mowing" +SERVICE_PAUSE = "pause" +SERVICE_DOCK = "dock" diff --git a/homeassistant/components/lawn_mower/manifest.json b/homeassistant/components/lawn_mower/manifest.json new file mode 100644 index 00000000000..43418a9440d --- /dev/null +++ b/homeassistant/components/lawn_mower/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "lawn_mower", + "name": "Lawn Mower", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/lawn_mower", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/lawn_mower/services.yaml b/homeassistant/components/lawn_mower/services.yaml new file mode 100644 index 00000000000..8c9a2f1adcc --- /dev/null +++ b/homeassistant/components/lawn_mower/services.yaml @@ -0,0 +1,22 @@ +# Describes the format for available lawn_mower services + +start_mowing: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.START_MOWING + +dock: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.DOCK + +pause: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.PAUSE diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json new file mode 100644 index 00000000000..caf2e15df77 --- /dev/null +++ b/homeassistant/components/lawn_mower/strings.json @@ -0,0 +1,28 @@ +{ + "title": "Lawn mower", + "entity_component": { + "_": { + "name": "[%key:component::lawn_mower::title%]", + "state": { + "error": "Error", + "paused": "Paused", + "mowing": "Mowing", + "docked": "Docked" + } + } + }, + "services": { + "start_mowing": { + "name": "Start mowing", + "description": "Starts the mowing task." + }, + "dock": { + "name": "Return to dock", + "description": "Stops the mowing task and returns to the dock." + }, + "pause": { + "name": "Pause", + "description": "Pauses the mowing task." + } + } +} diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 7ef7eb73673..527c3de7c9e 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -21,7 +21,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/ld2410_ble/binary_sensor.py b/homeassistant/components/ld2410_ble/binary_sensor.py index 59580d5725e..cca87de7a60 100644 --- a/homeassistant/components/ld2410_ble/binary_sensor.py +++ b/homeassistant/components/ld2410_ble/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 1a613a82098..798a80147de 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.6.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.11.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/ld2410_ble/sensor.py b/homeassistant/components/ld2410_ble/sensor.py index 806832e9fca..5bd4a0d4d2d 100644 --- a/homeassistant/components/ld2410_ble/sensor.py +++ b/homeassistant/components/ld2410_ble/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 768300ff534..1bdb8bf8ec9 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -5,7 +5,6 @@ import asyncio from datetime import timedelta import logging -import async_timeout from led_ble import BLEAK_EXCEPTIONS, LEDBLE from homeassistant.components import bluetooth @@ -78,7 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise try: - async with async_timeout.timeout(DEVICE_TIMEOUT): + async with asyncio.timeout(DEVICE_TIMEOUT): await startup_event.wait() except asyncio.TimeoutError as ex: raise ConfigEntryNotReady( diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 94f445f1ec1..5fba73ef808 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 5a1eef40001..da5b4b0a4ee 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.6.1", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.11.0", "led-ble==1.0.0"] } diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index 577b4a8811a..54d9be78df9 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -11,8 +11,11 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import DOMAIN + async def async_setup_entry( hass: HomeAssistant, @@ -42,6 +45,8 @@ class LGDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, host, port, unique_id): """Initialize the LG speakers.""" @@ -66,6 +71,9 @@ class LGDevice(MediaPlayerEntity): self._bass = 0 self._treble = 0 self._device = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=host + ) async def async_added_to_hass(self) -> None: """Register the callback after hass is ready for it.""" diff --git a/homeassistant/components/lidarr/__init__.py b/homeassistant/components/lidarr/__init__.py index 9222164227b..dd63920b209 100644 --- a/homeassistant/components/lidarr/__init__.py +++ b/homeassistant/components/lidarr/__init__.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py index 5153e389d8b..4b59bcadf88 100644 --- a/homeassistant/components/life360/config_flow.py +++ b/homeassistant/components/life360/config_flow.py @@ -137,6 +137,12 @@ class Life360ConfigFlow(ConfigFlow, domain=DOMAIN): 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") diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index f16e7215a22..ee097b9e989 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -10,6 +10,7 @@ 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 @@ -99,6 +100,8 @@ class Life360DeviceTracker( _attr_attribution = ATTRIBUTION _attr_unique_id: str + _attr_has_entity_name = True + _attr_name = None def __init__( self, coordinator: Life360DataUpdateCoordinator, member_id: str @@ -110,7 +113,7 @@ class Life360DeviceTracker( self._data: Life360Member | None = coordinator.data.members[member_id] self._prev_data = self._data - self._attr_name = self._data.name + 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 @@ -124,6 +127,11 @@ class Life360DeviceTracker( 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.""" diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 41aa58fb962..76d4b7e36c5 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -30,7 +30,7 @@ from .coordinator import LIFXUpdateCoordinator from .discovery import async_discover_devices, async_trigger_discovery from .manager import LIFXManager from .migration import async_migrate_entities_devices, async_migrate_legacy_entries -from .util import async_entry_is_legacy, async_get_legacy_entry +from .util import async_entry_is_legacy, async_get_legacy_entry, formatted_serial CONF_SERVER = "server" CONF_BROADCAST = "broadcast" @@ -218,6 +218,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connection.async_stop() raise + serial = formatted_serial(coordinator.serial_number) + if serial != entry.unique_id: + # If the serial number of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, found {serial}" + ) domain_data[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index 5f08b6e7884..4bc6b87393d 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from aiolifx import products from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 0e56155832f..e04e8afb3df 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -8,7 +8,6 @@ from typing import Any import aiolifx_effects as aiolifx_effects_module import voluptuous as vol -from homeassistant import util from homeassistant.components.light import ( ATTR_EFFECT, ATTR_TRANSITION, @@ -24,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform 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.event import async_call_later from .const import ( _LOGGER, @@ -187,10 +186,10 @@ class LIFXLight(LIFXEntity, LightEntity): """Refresh the state.""" await self.coordinator.async_refresh() - self.postponed_update = async_track_point_in_utc_time( + self.postponed_update = async_call_later( self.hass, + timedelta(milliseconds=when), _async_refresh, - util.dt.utcnow() + timedelta(milliseconds=when), ) async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 9d155ae32ae..c327081fabd 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -101,7 +101,7 @@ }, "color_name": { "name": "Color name", - "description": "A human readable color name." + "description": "A human-readable color name." }, "rgb_color": { "name": "RGB color", diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index ce03a595f64..bcf8ed1dc2c 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -8,7 +8,6 @@ from typing import Any import aiohttp from aiohttp.hdrs import AUTHORIZATION -import async_timeout import voluptuous as vol from homeassistant.components.scene import Scene @@ -48,7 +47,7 @@ async def async_setup_platform( try: httpsession = async_get_clientsession(hass) - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): scenes_resp = await httpsession.get(url, headers=headers) except (asyncio.TimeoutError, aiohttp.ClientError): @@ -90,7 +89,7 @@ class LifxCloudScene(Scene): try: httpsession = async_get_clientsession(self.hass) - async with async_timeout.timeout(self._timeout): + async with asyncio.timeout(self._timeout): await httpsession.put(url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 5398d38ca5d..8be954f4653 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -264,7 +264,7 @@ }, "color_name": { "name": "Color name", - "description": "A human readable color name." + "description": "A human-readable color name." }, "hs_color": { "name": "Hue/Sat color", @@ -308,7 +308,7 @@ }, "flash": { "name": "Flash", - "description": "If the light should flash." + "description": "Tell light to flash, can be either value short or long." }, "effect": { "name": "Effect", diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 801f104bd3b..6677768dd00 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -278,7 +278,6 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): return ColorMode.COLOR_TEMP return ColorMode.HS - # pylint: disable=arguments-differ @state(False) def turn_off(self, transition_time: int, pipeline: Pipeline, **kwargs: Any) -> None: """Turn off a group.""" @@ -286,7 +285,6 @@ class LimitlessLEDGroup(LightEntity, RestoreEntity): pipeline.transition(transition_time, brightness=0.0) pipeline.off() - # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time: int, pipeline: Pipeline, **kwargs: Any) -> None: """Turn on (or adjust property of) a group.""" diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py index 2c63bbc0bc8..17a68e9be9c 100644 --- a/homeassistant/components/linode/binary_sensor.py +++ b/homeassistant/components/linode/binary_sensor.py @@ -61,7 +61,7 @@ class LinodeBinarySensor(BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING - def __init__(self, li, node_id): # pylint: disable=invalid-name + def __init__(self, li, node_id): """Initialize a new Linode sensor.""" self._linode = li self._node_id = node_id diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py index 183abbc068c..b59e8f901e5 100644 --- a/homeassistant/components/linode/switch.py +++ b/homeassistant/components/linode/switch.py @@ -57,7 +57,7 @@ def setup_platform( class LinodeSwitch(SwitchEntity): """Representation of a Linode Node switch.""" - def __init__(self, li, node_id): # pylint: disable=invalid-name + def __init__(self, li, node_id): """Initialize a new Linode sensor.""" self._linode = li self._node_id = node_id diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index 181783b6bbd..1b9688906ff 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -1,5 +1,4 @@ """Support for LIRC devices.""" -# pylint: disable=import-error import logging import threading import time diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 291333d0b74..8c6d5ef4487 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -36,8 +36,8 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LiteJet component.""" - if DOMAIN in config and not hass.config_entries.async_entries(DOMAIN): - # No config entry exists and configuration.yaml config exists, trigger the import flow. + 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] diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index c469d480ca6..1062e948090 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -9,9 +9,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PORT -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, callback +from homeassistant.data_entry_flow import FlowResult, FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -53,6 +54,21 @@ 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 = {} @@ -62,6 +78,20 @@ 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() @@ -78,7 +108,24 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: """Import litejet config from configuration.yaml.""" - return self.async_create_entry(title=import_data[CONF_PORT], data=import_data) + 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 diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 9b771bdc035..167f7a62a00 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_DEFAULT_TRANSITION, DOMAIN diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index 83eb2cc5f0b..ce04a537559 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -8,7 +8,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/litejet/strings.json b/homeassistant/components/litejet/strings.json index 398f1a1e5aa..288e5f959a8 100644 --- a/homeassistant/components/litejet/strings.json +++ b/homeassistant/components/litejet/strings.json @@ -25,5 +25,11 @@ } } } + }, + "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/litejet/switch.py b/homeassistant/components/litejet/switch.py index 97a51223429..025770cdc35 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/litterrobot/__init__.py b/homeassistant/components/litterrobot/__init__.py index c7eda2f118b..daf71fe8a6e 100644 --- a/homeassistant/components/litterrobot/__init__.py +++ b/homeassistant/components/litterrobot/__init__.py @@ -6,6 +6,7 @@ from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Ro from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN from .hub import LitterRobotHub @@ -58,3 +59,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + for robot in hub.account.robots + if robot.serial == identifier[1] + ) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 5308a3b4f83..0872c5c831d 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -48,7 +48,7 @@ class LitterRobotBinarySensorEntity(LitterRobotEntity[_RobotT], BinarySensorEnti BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { - LitterRobot: ( + LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check RobotBinarySensorEntityDescription[LitterRobot]( key="sleeping", translation_key="sleeping", @@ -66,7 +66,7 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . is_on_fn=lambda robot: robot.sleep_mode_enabled, ), ), - Robot: ( + Robot: ( # type: ignore[type-abstract] # only used for isinstance check RobotBinarySensorEntityDescription[Robot]( key="power_status", translation_key="power_status", diff --git a/homeassistant/components/litterrobot/entity.py b/homeassistant/components/litterrobot/entity.py index 063799868b6..fb1fbe58a7b 100644 --- a/homeassistant/components/litterrobot/entity.py +++ b/homeassistant/components/litterrobot/entity.py @@ -6,7 +6,8 @@ from typing import Generic, TypeVar from pylitterbot import Robot from pylitterbot.robot import EVENT_UPDATE -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 6fabd6ea526..7f2ea62f956 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -48,7 +48,7 @@ class RobotSelectEntityDescription( ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { - LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( + LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( # type: ignore[type-abstract] # only used for isinstance check key="cycle_delay", translation_key="cycle_delay", icon="mdi:timer-outline", diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index ba601a0ba54..935bbaca595 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -66,7 +66,7 @@ class LitterRobotSensorEntity(LitterRobotEntity[_RobotT], SensorEntity): ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { - LitterRobot: [ + LitterRobot: [ # type: ignore[type-abstract] # only used for isinstance check RobotSensorEntityDescription[LitterRobot]( key="waste_drawer_level", translation_key="waste_drawer", diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 8436d24902c..7acfad69735 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -131,11 +131,6 @@ "litter_box": { "name": "Litter box" } - }, - "update": { - "firmware": { - "name": "Firmware" - } } }, "services": { diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 9b8391c5bae..584a6af77c2 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -24,7 +24,6 @@ SCAN_INTERVAL = timedelta(days=1) FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription( key="firmware", - translation_key="firmware", device_class=UpdateDeviceClass.FIRMWARE, ) diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index ebd2b813852..5ddba1e2e86 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -8,8 +8,8 @@ from aiolivisi.const import CAPABILITY_MAP from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py index 51b53ff0073..c3b9e5d151c 100644 --- a/homeassistant/components/local_calendar/diagnostics.py +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -19,7 +19,7 @@ async def async_get_config_entry_diagnostics( payload: dict[str, Any] = { "now": dt_util.now().isoformat(), "timezone": str(dt_util.DEFAULT_TIME_ZONE), - "system_timezone": str(datetime.datetime.utcnow().astimezone().tzinfo), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index b56acffe4e2..acc2ac80caa 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.5.4"] + "requirements": ["ical==5.0.1"] } diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index e351ee6bb61..82c05e612e3 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -65,7 +65,7 @@ class LazyEventPartialState: self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin # We need to explicitly check for the row is EventAsRow as the unhappy path # to fetch row.data for Row is very expensive - if type(row) is EventAsRow: # pylint: disable=unidiomatic-typecheck + if type(row) is EventAsRow: # noqa: E721 # If its an EventAsRow we can avoid the whole # json decode process as we already have the data self.data = row.data diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index cd2761510d3..e7f3d6b78f1 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -126,7 +126,6 @@ def _get_logger_class(hass_overrides: dict[str, int]) -> type[logging.Logger]: super().setLevel(level) - # pylint: disable=invalid-name def orig_setLevel(self, level: int | str) -> None: """Set the log level.""" super().setLevel(level) diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 93e23be5d8d..a14cd60c993 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -2,7 +2,6 @@ import asyncio from aiohttp.client_exceptions import ClientResponseError -import async_timeout from logi_circle import LogiCircle from logi_circle.exception import AuthorizationFailed import voluptuous as vol @@ -154,7 +153,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False try: - async with async_timeout.timeout(_TIMEOUT): + async with asyncio.timeout(_TIMEOUT): # Ensure the cameras property returns the same Camera objects for # all devices. Performs implicit login and session validation. await logi_circle.synchronize_cameras() diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 4a8b36a3d55..5c27d2a08ae 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -15,8 +15,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -53,7 +53,7 @@ async def async_setup_entry( devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras ffmpeg = get_ffmpeg_manager(hass) - cameras = [LogiCam(device, entry, ffmpeg) for device in devices] + cameras = [LogiCam(device, ffmpeg) for device in devices] async_add_entities(cameras, True) @@ -64,12 +64,13 @@ class LogiCam(Camera): _attr_attribution = ATTRIBUTION _attr_should_poll = True # Cameras default to False _attr_supported_features = CameraEntityFeature.ON_OFF + _attr_has_entity_name = True + _attr_name = None - def __init__(self, camera, device_info, ffmpeg): + def __init__(self, camera, ffmpeg): """Initialize Logi Circle camera.""" super().__init__() self._camera = camera - self._name = self._camera.name self._id = self._camera.mac_address self._has_battery = self._camera.supports_feature("battery_level") self._ffmpeg = ffmpeg @@ -121,11 +122,6 @@ class LogiCam(Camera): """Return a unique ID.""" return self._id - @property - def name(self): - """Return the name of this camera.""" - return self._name - @property def device_info(self) -> DeviceInfo: """Return information about the device.""" diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index ff7528ac9f6..9785940aca2 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -3,7 +3,6 @@ import asyncio from collections import OrderedDict from http import HTTPStatus -import async_timeout from logi_circle import LogiCircle from logi_circle.exception import AuthorizationFailed import voluptuous as vol @@ -158,7 +157,7 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) try: - async with async_timeout.timeout(_TIMEOUT): + async with asyncio.timeout(_TIMEOUT): await logi_session.authorize(code) except AuthorizationFailed: (self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]) = "invalid_auth" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index b27ba30128f..32082b794b7 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -4,7 +4,11 @@ from __future__ import annotations import logging from typing import Any -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -15,9 +19,8 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import as_local @@ -29,34 +32,33 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="battery_level", - name="Battery", native_unit_of_measurement=PERCENTAGE, - icon="mdi:battery-50", + device_class=SensorDeviceClass.BATTERY, ), SensorEntityDescription( key="last_activity_time", - name="Last Activity", + translation_key="last_activity", icon="mdi:history", ), SensorEntityDescription( key="recording", - name="Recording Mode", + translation_key="recording_mode", icon="mdi:eye", ), SensorEntityDescription( key="signal_strength_category", - name="WiFi Signal Category", + translation_key="wifi_signal_category", icon="mdi:wifi", ), SensorEntityDescription( key="signal_strength_percentage", - name="WiFi Signal Strength", + translation_key="wifi_signal_strength", native_unit_of_measurement=PERCENTAGE, icon="mdi:wifi", ), SensorEntityDescription( key="streaming", - name="Streaming Mode", + translation_key="streaming_mode", icon="mdi:camera", ), ) @@ -95,13 +97,13 @@ class LogiSensor(SensorEntity): """A sensor implementation for a Logi Circle camera.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__(self, camera, time_zone, description: SensorEntityDescription) -> None: """Initialize a sensor for Logi Circle camera.""" self.entity_description = description self._camera = camera self._attr_unique_id = f"{camera.mac_address}-{description.key}" - self._attr_name = f"{camera.name} {description.name}" self._activity: dict[Any, Any] = {} self._tz = time_zone @@ -135,10 +137,6 @@ class LogiSensor(SensorEntity): def icon(self): """Icon to use in the frontend, if any.""" sensor_type = self.entity_description.key - if sensor_type == "battery_level" and self._attr_native_value is not None: - return icon_for_battery_level( - battery_level=int(self._attr_native_value), charging=False - ) if sensor_type == "recording_mode" and self._attr_native_value is not None: return "mdi:eye" if self._attr_native_value == STATE_ON else "mdi:eye-off" if sensor_type == "streaming_mode" and self._attr_native_value is not None: diff --git a/homeassistant/components/logi_circle/strings.json b/homeassistant/components/logi_circle/strings.json index 4f641238a49..188139e6c29 100644 --- a/homeassistant/components/logi_circle/strings.json +++ b/homeassistant/components/logi_circle/strings.json @@ -25,6 +25,25 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]" } }, + "entity": { + "sensor": { + "last_activity": { + "name": "Last activity" + }, + "recording_mode": { + "name": "Recording mode" + }, + "wifi_signal_category": { + "name": "Wi-Fi signal category" + }, + "wifi_signal_strength": { + "name": "Wi-Fi signal strength" + }, + "streaming_mode": { + "name": "Streaming mode" + } + } + }, "services": { "set_config": { "name": "Set config", diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 8217b3913a8..7e52186fa51 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -1,10 +1,10 @@ """Sensor for checking the status of London Underground tube lines.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from london_tube_status import TubeData import voluptuous as vol @@ -90,7 +90,7 @@ class LondonTubeCoordinator(DataUpdateCoordinator): self._data = data async def _async_update_data(self): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self._data.update() return self._data.data diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index c16d7f34f0f..7656de8d385 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -104,6 +104,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, aiohttp.ClientError, NoUsableService) as ex: raise ConfigEntryNotReady from ex + if entry.unique_id != (found_uuid := lookin_device.id.upper()): + # If the uuid of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, " + f"found {found_uuid}" + ) + push_coordinator = LookinPushCoordinator(entry.title) if lookin_device.model >= 2: diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index 1bdbb36dd71..d556899a914 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -60,6 +60,7 @@ class LookinDataUpdateCoordinator(DataUpdateCoordinator[_DataT]): name=name, update_interval=update_interval, update_method=update_method, + always_update=False, ) @callback diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index 35de968cf2f..d20a21bd23c 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -14,7 +14,7 @@ from aiolookin import ( ) from aiolookin.models import Device, UDPCommandType, UDPEvent -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODEL_NAMES diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index e6c69e0751e..3ee65f751ae 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -35,10 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = LoqedDataCoordinator(hass, api, lock, entry) await coordinator.ensure_webhooks() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 5eecc0b3f59..911ccb0ff5b 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -123,7 +123,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_data_schema, description_placeholders={ - "config_url": "https://integrations.production.loqed.com/personal-access-tokens", + "config_url": "https://integrations.loqed.com/personal-access-tokens", }, ) @@ -156,7 +156,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=user_data_schema, errors=errors, description_placeholders={ - "config_url": "https://integrations.production.loqed.com/personal-access-tokens", + "config_url": "https://integrations.loqed.com/personal-access-tokens", }, ) diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index 42e0d523aba..d33cd8772b2 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -1,9 +1,9 @@ """Provides the coordinator for a LOQED lock.""" +import asyncio import logging from typing import TypedDict from aiohttp.web import Request -import async_timeout from loqedAPI import loqed from homeassistant.components import cloud, webhook @@ -86,7 +86,7 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): async def _async_update_data(self) -> StatusMessage: """Fetch data from API endpoint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._api.async_get_lock_details() async def _handle_webhook( diff --git a/homeassistant/components/loqed/entity.py b/homeassistant/components/loqed/entity.py index 978fe844d61..aec50ec8f92 100644 --- a/homeassistant/components/loqed/entity.py +++ b/homeassistant/components/loqed/entity.py @@ -1,8 +1,7 @@ """Base entity for the LOQED integration.""" from __future__ import annotations -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index 59b91fea195..e4cd4b71045 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -3,7 +3,7 @@ "flow_title": "LOQED Touch Smartlock setup", "step": { "user": { - "description": "Login at {config_url} and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", + "description": "Login at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", "data": { "name": "Name of your lock in the LOQED app.", "api_token": "[%key:common::config_flow::data::api_token%]" diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index cca467ce756..58fa5788bda 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index da2c03745fa..41369046d51 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -8,7 +8,6 @@ import logging import ssl from typing import Any, cast -import async_timeout from pylutron_caseta import BUTTON_STATUS_PRESSED from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol @@ -19,7 +18,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import ( @@ -172,7 +172,7 @@ async def async_setup_entry( timed_out = True with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(BRIDGE_TIMEOUT): + async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() timed_out = False diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 29e59c426b5..334590c0e65 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -8,8 +8,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUGGESTED_AREA from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice, _area_name_from_id diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index 1c01ed651fd..d31e4579675 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDevice diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 74819e25e8e..9b243a3ec98 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -6,7 +6,6 @@ import logging import os import ssl -import async_timeout from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY, async_pair from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol @@ -226,7 +225,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return None try: - async with async_timeout.timeout(BRIDGE_TIMEOUT): + async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() except asyncio.TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py index 61f00a1b09f..91b042106cb 100644 --- a/homeassistant/components/lutron_caseta/models.py +++ b/homeassistant/components/lutron_caseta/models.py @@ -7,7 +7,7 @@ from typing import Any, Final, TypedDict from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo @dataclass diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index 997397c5b6c..520dcd965f2 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -6,7 +6,7 @@ from pylutron_caseta.smartbridge import Smartbridge from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as CASETA_DOMAIN diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index c2423a7c47f..3e83fedb72a 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -1,6 +1,7 @@ """The Honeywell Lyric integration.""" from __future__ import annotations +import asyncio from datetime import timedelta from http import HTTPStatus import logging @@ -10,7 +11,6 @@ from aiolyric import Lyric from aiolyric.exceptions import LyricAuthenticationException, LyricException from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -22,7 +22,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -74,7 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(exception) from exception try: - async with async_timeout.timeout(60): + async with asyncio.timeout(60): await lyric.get_locations() return lyric except LyricAuthenticationException as exception: @@ -118,6 +118,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): """Defines a base Honeywell Lyric entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[Lyric], diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index df90ebcd6cf..ef662d061e8 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -138,6 +138,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): coordinator: DataUpdateCoordinator[Lyric] entity_description: ClimateEntityDescription + _attr_name = None + def __init__( self, coordinator: DataUpdateCoordinator[Lyric], diff --git a/homeassistant/components/lyric/manifest.json b/homeassistant/components/lyric/manifest.json index e517ce5118e..a55f9c1d7cb 100644 --- a/homeassistant/components/lyric/manifest.json +++ b/homeassistant/components/lyric/manifest.json @@ -22,5 +22,5 @@ "iot_class": "cloud_polling", "loggers": ["aiolyric"], "quality_scale": "silver", - "requirements": ["aiolyric==1.0.9"] + "requirements": ["aiolyric==1.1.0"] } diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 1e15ff58b18..d628a108183 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -86,7 +86,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_indoor_temperature", - name="Indoor Temperature", + translation_key="indoor_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=native_temperature_unit, @@ -102,7 +102,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_indoor_humidity", - name="Indoor Humidity", + translation_key="indoor_humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -123,7 +123,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_outdoor_temperature", - name="Outdoor Temperature", + translation_key="outdoor_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=native_temperature_unit, @@ -139,7 +139,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_outdoor_humidity", - name="Outdoor Humidity", + translation_key="outdoor_humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -156,7 +156,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_next_period_time", - name="Next Period Time", + translation_key="next_period_time", device_class=SensorDeviceClass.TIMESTAMP, value=lambda device: get_datetime_from_future_time( device.changeableValues.nextPeriodTime @@ -172,7 +172,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_setpoint_status", - name="Setpoint Status", + translation_key="setpoint_status", icon="mdi:thermostat", value=lambda device: get_setpoint_status( device.changeableValues.thermostatSetpointStatus, diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 2271d4201f6..219530a9747 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -18,6 +18,28 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "entity": { + "sensor": { + "indoor_temperature": { + "name": "Indoor temperature" + }, + "indoor_humidity": { + "name": "Indoor humidity" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "outdoor_humidity": { + "name": "Outdoor humidity" + }, + "next_period_time": { + "name": "Next period time" + }, + "setpoint_status": { + "name": "Setpoint status" + } + } + }, "services": { "set_hold_time": { "name": "Set Hold Time", diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 75cea546b71..679abfd3164 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -10,7 +10,6 @@ from typing import Any, Final from aiohttp import web from aiohttp.web_exceptions import HTTPNotFound -import async_timeout from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView @@ -267,7 +266,7 @@ class MailboxMediaView(MailboxView): mailbox = self.get_mailbox(platform) with suppress(asyncio.CancelledError, asyncio.TimeoutError): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: stream = await mailbox.async_get_media(msgid) except StreamError as err: diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py index 7bea67b596d..b7104d4a0f1 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -31,7 +31,6 @@ ATTR_IMAGES = "images" DEFAULT_SANDBOX = False -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_RECIPIENT): vol.Email(), vol.Optional(CONF_SENDER): vol.Email()} ) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 59c5ec9efc8..a2aa2c5ceff 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from contextlib import suppress -import async_timeout from matter_server.client import MatterClient from matter_server.client.exceptions import CannotConnect, InvalidServerVersion from matter_server.common.errors import MatterError, NodeCommissionFailed, NodeNotExists @@ -42,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: matter_client = MatterClient(entry.data[CONF_URL], async_get_clientsession(hass)) try: - async with async_timeout.timeout(CONNECT_TIMEOUT): + async with asyncio.timeout(CONNECT_TIMEOUT): await matter_client.connect() except (CannotConnect, asyncio.TimeoutError) as err: raise ConfigEntryNotReady("Failed to connect to matter server") from err @@ -87,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - async with async_timeout.timeout(LISTEN_READY_TIMEOUT): + async with asyncio.timeout(LISTEN_READY_TIMEOUT): await init_ready.wait() except asyncio.TimeoutError as err: listen_task.cancel() diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 0082370d5ff..102e0c83b7b 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -12,7 +12,8 @@ from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 3a1faa6dcbe..84049301296 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -65,7 +65,7 @@ class MatterEventEntity(MatterEntity, EventEntity): if feature_map & SwitchFeature.kMomentarySwitchRelease: event_types.append("short_release") if feature_map & SwitchFeature.kMomentarySwitchLongPress: - event_types.append("long_press_ongoing") + event_types.append("long_press") event_types.append("long_release") if feature_map & SwitchFeature.kMomentarySwitchMultiPress: event_types.append("multi_press_ongoing") diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index bb92496b74f..f375b8a75cd 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -1,11 +1,11 @@ """The Mazda Connected Services integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import TYPE_CHECKING -import async_timeout from pymazda import ( Client as MazdaAPI, MazdaAccountLockedException, @@ -29,7 +29,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -53,7 +53,7 @@ PLATFORMS = [ async def with_timeout(task, timeout_seconds=30): """Run an async task with a timeout.""" - async with async_timeout.timeout(timeout_seconds): + async with asyncio.timeout(timeout_seconds): return await task diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 6db3093567d..12fdb7f3a06 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -1,8 +1,8 @@ """The Meater Temperature Probe integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from meater import ( AuthenticationError, MeaterApi, @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): devices: list[MeaterProbe] = await meater_api.get_all_devices() except AuthenticationError as err: raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index cf71455a81b..98bb44947c8 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index d00f1b33ccc..dae734fc06f 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -1,5 +1,7 @@ """Decorator service for the media_player.play_media service.""" +from collections.abc import Callable import logging +from typing import Any, cast import voluptuous as vol from yt_dlp import YoutubeDL @@ -68,21 +70,26 @@ class MEQueryException(Exception): class MediaExtractor: """Class which encapsulates all extraction logic.""" - def __init__(self, hass, component_config, call_data): + def __init__( + self, + hass: HomeAssistant, + component_config: dict[str, Any], + call_data: dict[str, Any], + ) -> None: """Initialize media extractor.""" self.hass = hass self.config = component_config self.call_data = call_data - def get_media_url(self): + def get_media_url(self) -> str: """Return media content url.""" - return self.call_data.get(ATTR_MEDIA_CONTENT_ID) + return cast(str, self.call_data[ATTR_MEDIA_CONTENT_ID]) - def get_entities(self): + def get_entities(self) -> list[str]: """Return list of entities.""" return self.call_data.get(ATTR_ENTITY_ID, []) - def extract_and_send(self): + def extract_and_send(self) -> None: """Extract exact stream format for each entity_id and play it.""" try: stream_selector = self.get_stream_selector() @@ -97,7 +104,7 @@ class MediaExtractor: for entity_id in entities: self.call_media_player_service(stream_selector, entity_id) - def get_stream_selector(self): + def get_stream_selector(self) -> Callable[[str], str]: """Return format selector for the media URL.""" ydl = YoutubeDL({"quiet": True, "logger": _LOGGER}) @@ -118,7 +125,7 @@ class MediaExtractor: else: selected_media = all_media - def stream_selector(query): + def stream_selector(query: str) -> str: """Find stream URL that matches query.""" try: ydl.params["format"] = query @@ -131,12 +138,14 @@ class MediaExtractor: best_stream = requested_stream["formats"][ len(requested_stream["formats"]) - 1 ] - return best_stream["url"] - return requested_stream["url"] + return str(best_stream["url"]) + return str(requested_stream["url"]) return stream_selector - def call_media_player_service(self, stream_selector, entity_id): + def call_media_player_service( + self, stream_selector: Callable[[str], str], entity_id: str | None + ) -> None: """Call Media player play_media service.""" stream_query = self.get_stream_query_for_entity(entity_id) @@ -156,16 +165,16 @@ class MediaExtractor: self.hass.services.async_call(MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data) ) - def get_stream_query_for_entity(self, entity_id): + def get_stream_query_for_entity(self, entity_id: str | None) -> str: """Get stream format query for entity.""" - default_stream_query = self.config.get( + default_stream_query: str = self.config.get( CONF_DEFAULT_STREAM_QUERY, DEFAULT_STREAM_QUERY ) if entity_id: media_content_type = self.call_data.get(ATTR_MEDIA_CONTENT_TYPE) - return ( + return str( self.config.get(CONF_CUSTOMIZE_ENTITIES, {}) .get(entity_id, {}) .get(media_content_type, default_stream_query) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 0e5d9ead0f8..707cbdf9e8b 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -1,7 +1,7 @@ { "domain": "media_extractor", "name": "Media Extractor", - "codeowners": [], + "codeowners": ["@joostlek"], "dependencies": ["media_player"], "documentation": "https://www.home-assistant.io/integrations/media_extractor", "iot_class": "calculated", diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 39b67477f97..2acb516fa95 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -19,7 +19,6 @@ from urllib.parse import quote, urlparse from aiohttp import web from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.typedefs import LooseHeaders -import async_timeout import voluptuous as vol from yarl import URL @@ -1258,12 +1257,13 @@ async def async_fetch_image( """Retrieve an image.""" content, content_type = (None, None) websession = async_get_clientsession(hass) - with suppress(asyncio.TimeoutError), async_timeout.timeout(10): - response = await websession.get(url) - if response.status == HTTPStatus.OK: - content = await response.read() - if content_type := response.headers.get(CONTENT_TYPE): - content_type = content_type.split(";")[0] + with suppress(asyncio.TimeoutError): + async with asyncio.timeout(10): + response = await websession.get(url) + if response.status == HTTPStatus.OK: + content = await response.read() + if content_type := response.headers.get(CONTENT_TYPE): + content_type = content_type.split(";")[0] if content is None: url_parts = URL(url) diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 89437a6b2e0..ac6623a3af8 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -12,9 +12,9 @@ from aiohttp.web_request import FileField import voluptuous as vol from homeassistant.components import http, websocket_api +from homeassistant.components.http import require_admin from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import Unauthorized from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES @@ -254,11 +254,9 @@ class UploadMediaView(http.HomeAssistantView): } ) + @require_admin async def post(self, request: web.Request) -> web.Response: """Handle upload.""" - if not request["hass_user"].is_admin: - raise Unauthorized() - # Increase max payload request._client_max_size = MAX_UPLOAD_SIZE # pylint: disable=protected-access diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index eea169c3591..5f007f3a8e5 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -7,8 +7,8 @@ import logging from typing import Any from aiohttp import ClientConnectionError -from async_timeout import timeout from pymelcloud import Device, get_devices +from pymelcloud.atw_device import Zone import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -17,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import 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 -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -141,6 +140,17 @@ class MelCloudDevice: name=self.name, ) + def zone_device_info(self, zone: Zone) -> DeviceInfo: + """Return a zone device description for device registry.""" + dev = self.device + return DeviceInfo( + identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")}, + manufacturer="Mitsubishi Electric", + model="ATW zone device", + name=f"{self.name} {zone.name}", + 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.""" @@ -153,7 +163,7 @@ async def mel_devices_setup( """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) try: - async with timeout(10): + async with asyncio.timeout(10): all_devices = await get_devices( token, session, diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 2cac1abcf88..589223dc0f3 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -99,6 +99,8 @@ class MelCloudClimate(ClimateEntity): """Base climate device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_name = None def __init__(self, device: MelCloudDevice) -> None: """Initialize the climate.""" @@ -109,11 +111,6 @@ class MelCloudClimate(ClimateEntity): """Update state from MELCloud.""" await self.api.async_update() - @property - def device_info(self): - """Return a device description for device registry.""" - return self.api.device_info - @property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" @@ -134,8 +131,8 @@ class AtaDeviceClimate(MelCloudClimate): super().__init__(device) self._device = ata_device - self._attr_name = device.name self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}" + self._attr_device_info = self.api.device_info @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -310,8 +307,8 @@ class AtwDeviceZoneClimate(MelCloudClimate): self._device = atw_device self._zone = atw_zone - self._attr_name = f"{device.name} {self._zone.name}" self._attr_unique_id = f"{self.api.device.serial}-{atw_zone.zone_index}" + self._attr_device_info = self.api.zone_device_info(atw_zone) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 6b3eeaa19ae..0ff17ea751a 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -5,17 +5,52 @@ import asyncio from http import HTTPStatus from aiohttp import ClientError, ClientResponseError -from async_timeout import timeout import pymelcloud 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, FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN +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.""" @@ -24,7 +59,11 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _create_entry(self, username: str, token: str): """Register new entry.""" await self.async_set_unique_id(username) - self._abort_if_unique_id_configured({CONF_TOKEN: token}) + try: + self._abort_if_unique_id_configured({CONF_TOKEN: token}) + except AbortFlow: + await async_create_import_issue(self.hass, self.context["source"], "", True) + raise return self.async_create_entry( title=username, data={CONF_USERNAME: username, CONF_TOKEN: token} ) @@ -37,13 +76,8 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): token: str | None = None, ): """Create client.""" - if password is None and token is None: - raise ValueError( - "Invalid internal state. Called without either password or token" - ) - try: - async with timeout(10): + async with asyncio.timeout(10): if (acquired_token := token) is None: acquired_token = await pymelcloud.login( username, @@ -56,9 +90,18 @@ 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) @@ -77,6 +120,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, user_input): """Import a config entry.""" - return await self._create_client( + 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 diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 74187948d7d..ca02d15db01 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -41,28 +41,30 @@ class MelcloudSensorEntityDescription( ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( key="room_temperature", - name="Room Temperature", + translation_key="room_temperature", icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: x.device.room_temperature, enabled=lambda x: True, ), MelcloudSensorEntityDescription( key="energy", - name="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.total_energy_consumed, enabled=lambda x: x.device.has_energy_consumed_meter, ), MelcloudSensorEntityDescription( key="daily_energy", - name="Daily Energy Consumed", + 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, ), @@ -70,28 +72,31 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( key="outside_temperature", - name="Outside Temperature", + translation_key="outside_temperature", icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: x.device.outside_temperature, enabled=lambda x: True, ), MelcloudSensorEntityDescription( key="tank_temperature", - name="Tank Temperature", + translation_key="tank_temperature", icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda x: x.device.tank_temperature, enabled=lambda x: True, ), MelcloudSensorEntityDescription( key="daily_energy", - name="Daily Energy Consumed", + 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, ), @@ -99,28 +104,31 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( key="room_temperature", - name="Room Temperature", + translation_key="room_temperature", icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda zone: zone.room_temperature, enabled=lambda x: True, ), MelcloudSensorEntityDescription( key="flow_temperature", - name="Flow Temperature", + translation_key="flow_temperature", icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda zone: zone.flow_temperature, enabled=lambda x: True, ), MelcloudSensorEntityDescription( key="return_temperature", - name="Flow Return Temperature", + translation_key="return_temperature", icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, value_fn=lambda zone: zone.return_temperature, enabled=lambda x: True, ), @@ -160,6 +168,7 @@ class MelDeviceSensor(SensorEntity): """Representation of a Sensor.""" entity_description: MelcloudSensorEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -170,16 +179,11 @@ class MelDeviceSensor(SensorEntity): self._api = api self.entity_description = description - self._attr_name = f"{api.name} {description.name}" self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}" - - if description.device_class == SensorDeviceClass.ENERGY: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - else: - self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_info = api.device_info @property - def native_value(self): + def native_value(self) -> float | None: """Return the state of the sensor.""" return self.entity_description.value_fn(self._api) @@ -187,11 +191,6 @@ class MelDeviceSensor(SensorEntity): """Retrieve latest state.""" await self._api.async_update() - @property - def device_info(self): - """Return a device description for device registry.""" - return self._api.device_info - class AtwZoneSensor(MelDeviceSensor): """Air-to-Air device sensor.""" @@ -206,10 +205,11 @@ class AtwZoneSensor(MelDeviceSensor): if zone.zone_index != 1: description.key = f"{description.key}-zone-{zone.zone_index}" super().__init__(api, description) + + self._attr_device_info = api.zone_device_info(zone) self._zone = zone - self._attr_name = f"{api.name} {zone.name} {description.name}" @property - def native_value(self): + def native_value(self) -> float | None: """Return zone based state.""" return self.entity_description.value_fn(self._zone) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index e5447952c5e..eefd5a07a8d 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -40,5 +40,37 @@ } } } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The MELCloud YAML configuration import failed", + "description": "Configuring MELCloud using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The MELCloud YAML configuration import failed", + "description": "Configuring MELCloud using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." + } + }, + "entity": { + "sensor": { + "room_temperature": { + "name": "Room temperature" + }, + "daily_energy": { + "name": "Daily energy consumed" + }, + "outside_temperature": { + "name": "Outside temperature" + }, + "tank_temperature": { + "name": "Tank temperature" + }, + "flow_temperature": { + "name": "Flow temperature" + }, + "return_temperature": { + "name": "Flow return temperature" + } + } } } diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index cf4b788480f..210b8bd51e2 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -47,32 +47,20 @@ class AtwWaterHeater(WaterHeaterEntity): | WaterHeaterEntityFeature.ON_OFF | WaterHeaterEntityFeature.OPERATION_MODE ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None: """Initialize water heater device.""" self._api = api self._device = device - self._name = device.name + self._attr_unique_id = api.device.serial + self._attr_device_info = api.device_info async def async_update(self) -> None: """Update state from MELCloud.""" await self._api.async_update() - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return f"{self._api.device.serial}" - - @property - def name(self): - """Return the display name of this entity.""" - return self._name - - @property - def device_info(self): - """Return a device description for device registry.""" - return self._api.device_info - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._device.set({PROPERTY_POWER: True}) @@ -82,7 +70,7 @@ class AtwWaterHeater(WaterHeaterEntity): await self._device.set({PROPERTY_POWER: False}) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the optional state attributes with device specific additions.""" data = {ATTR_STATUS: self._device.status} return data @@ -108,7 +96,7 @@ class AtwWaterHeater(WaterHeaterEntity): return self._device.tank_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._device.target_tank_temperature diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py index 8cbe5f80680..409cb9ae3ba 100644 --- a/homeassistant/components/melnor/models.py +++ b/homeassistant/components/melnor/models.py @@ -9,7 +9,7 @@ from melnor_bluetooth.device import Device, Valve from homeassistant.components.number import EntityDescription from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index d8cb31077c2..d36a9e58eb7 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -33,7 +33,7 @@ from .const import ( @callback def configured_instances(hass: HomeAssistant) -> set[str]: - """Return a set of configured SimpliSafe instances.""" + """Return a set of configured met.no instances.""" entries = [] for entry in hass.config_entries.async_entries(DOMAIN): if entry.data.get("track_home"): diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index dcc493570ba..b690f1b6723 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -22,6 +22,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, @@ -199,4 +200,5 @@ ATTR_MAP = { ATTR_WEATHER_WIND_SPEED: "wind_speed", ATTR_WEATHER_WIND_GUST_SPEED: "wind_gust", ATTR_WEATHER_CLOUD_COVERAGE: "cloudiness", + ATTR_WEATHER_DEW_POINT: "dew_point", } diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index 5c476b10665..d6466bb64c4 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["PyMetno==0.10.0"] + "requirements": ["PyMetno==0.11.0"] } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 20822dc9973..a5a0d34d4eb 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -8,14 +8,17 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,11 +30,10 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.core import HomeAssistant, callback +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 homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator @@ -47,19 +49,38 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator: MetDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [ - MetWeather( - coordinator, - config_entry.data, - hass.config.units is METRIC_SYSTEM, - False, - ), + entity_registry = er.async_get(hass) + + entities = [ + MetWeather( + coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, False + ) + ] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.data, True), + ): + entities.append( MetWeather( coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, True - ), - ] - ) + ) + ) + + async_add_entities(entities) + + +def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: + """Calculate unique ID.""" + name_appendix = "" + if hourly: + name_appendix = "-hourly" + if config.get(CONF_TRACK_HOME): + return f"home{name_appendix}" + + return f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}{name_appendix}" def format_condition(condition: str) -> str: @@ -70,7 +91,7 @@ def format_condition(condition: str) -> str: return condition -class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): +class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): """Implementation of a Met.no weather condition.""" _attr_attribution = ( @@ -82,6 +103,9 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__( self, @@ -92,6 +116,7 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) + self._attr_unique_id = _calculate_unique_id(config, hourly) self._config = config self._is_metric = is_metric self._hourly = hourly @@ -101,17 +126,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Return if we are tracking home.""" return self._config.get(CONF_TRACK_HOME, False) - @property - def unique_id(self) -> str: - """Return unique ID.""" - name_appendix = "" - if self._hourly: - name_appendix = "-hourly" - if self.track_home: - return f"home{name_appendix}" - - return f"{self._config[CONF_LATITUDE]}-{self._config[CONF_LONGITUDE]}{name_appendix}" - @property def name(self) -> str: """Return the name of the sensor.""" @@ -191,9 +205,15 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ) @property - def forecast(self) -> list[Forecast] | None: + def native_dew_point(self) -> float | None: + """Return the dew point.""" + return self.coordinator.data.current_weather_data.get( + ATTR_MAP[ATTR_WEATHER_DEW_POINT] + ) + + def _forecast(self, hourly: bool) -> list[Forecast] | None: """Return the forecast array.""" - if self._hourly: + if hourly: met_forecast = self.coordinator.data.hourly_forecast else: met_forecast = self.coordinator.data.daily_forecast @@ -214,6 +234,21 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ha_forecast.append(ha_item) # type: ignore[arg-type] return ha_forecast + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast array.""" + return self._forecast(self._hourly) + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self._forecast(False) + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return self._forecast(True) + @property def device_info(self) -> DeviceInfo: """Device info.""" diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index a5b096b5554..042eb6f458f 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,6 +1,7 @@ """The met_eireann component.""" from datetime import timedelta import logging +from typing import Self import meteireann @@ -33,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b weather_data = MetEireannWeatherData(hass, config_entry.data, raw_weather_data) - async def _async_update_data(): + async def _async_update_data() -> MetEireannWeatherData: """Fetch data from Met Éireann.""" try: return await weather_data.fetch_data() @@ -78,7 +79,7 @@ class MetEireannWeatherData: self.daily_forecast = None self.hourly_forecast = None - async def fetch_data(self): + async def fetch_data(self) -> Self: """Fetch data from API - (current weather and forecast).""" await self._weather_data.fetching_data() self.current_weather_data = self._weather_data.get_current_weather() diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index 1cab9c9099f..9316aad1b17 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -9,13 +9,11 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY, ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, - ATTR_FORECAST_CONDITION, ATTR_FORECAST_NATIVE_PRESSURE, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, ) @@ -29,12 +27,10 @@ HOME_LOCATION_NAME = "Home" ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.met_eireann_{HOME_LOCATION_NAME}" FORECAST_MAP = { - ATTR_FORECAST_CONDITION: "condition", ATTR_FORECAST_NATIVE_PRESSURE: "pressure", ATTR_FORECAST_PRECIPITATION: "precipitation", ATTR_FORECAST_NATIVE_TEMP: "temperature", ATTR_FORECAST_NATIVE_TEMP_LOW: "templow", - ATTR_FORECAST_TIME: "datetime", ATTR_FORECAST_WIND_BEARING: "wind_bearing", ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", } diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index bf0d7214c6e..3a45a74c36b 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -1,10 +1,15 @@ """Support for Met Éireann weather service.""" import logging +from types import MappingProxyType +from typing import Any, cast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, - WeatherEntity, + DOMAIN as WEATHER_DOMAIN, + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -16,19 +21,20 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.core import HomeAssistant, callback +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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util +from . import MetEireannWeatherData from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP _LOGGER = logging.getLogger(__name__) -def format_condition(condition: str): +def format_condition(condition: str | None) -> str | None: """Map the conditions provided by the weather API to those supported by the frontend.""" if condition is not None: for key, value in CONDITION_MAP.items(): @@ -44,15 +50,33 @@ async def async_setup_entry( ) -> None: """Add a weather entity from a config_entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [ - MetEireannWeather(coordinator, config_entry.data, False), - MetEireannWeather(coordinator, config_entry.data, True), - ] - ) + entity_registry = er.async_get(hass) + + entities = [MetEireannWeather(coordinator, config_entry.data, False)] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.data, True), + ): + entities.append(MetEireannWeather(coordinator, config_entry.data, True)) + + async_add_entities(entities) -class MetEireannWeather(CoordinatorEntity, WeatherEntity): +def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> str: + """Calculate unique ID.""" + name_appendix = "" + if hourly: + name_appendix = "-hourly" + + return f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}{name_appendix}" + + +class MetEireannWeather( + SingleCoordinatorWeatherEntity[DataUpdateCoordinator[MetEireannWeatherData]] +): """Implementation of a Met Éireann weather condition.""" _attr_attribution = "Data provided by Met Éireann" @@ -60,22 +84,17 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__(self, coordinator, config, hourly): """Initialise the platform with a data instance and site.""" super().__init__(coordinator) + self._attr_unique_id = _calculate_unique_id(config, hourly) self._config = config self._hourly = hourly - @property - def unique_id(self): - """Return unique ID.""" - name_appendix = "" - if self._hourly: - name_appendix = "-hourly" - - return f"{self._config[CONF_LATITUDE]}-{self._config[CONF_LONGITUDE]}{name_appendix}" - @property def name(self): """Return the name of the sensor.""" @@ -126,35 +145,53 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Return the wind direction.""" return self.coordinator.data.current_weather_data.get("wind_bearing") - @property - def forecast(self): + def _forecast(self, hourly: bool) -> list[Forecast]: """Return the forecast array.""" - if self._hourly: + if hourly: me_forecast = self.coordinator.data.hourly_forecast else: me_forecast = self.coordinator.data.daily_forecast required_keys = {"temperature", "datetime"} - ha_forecast = [] + ha_forecast: list[Forecast] = [] for item in me_forecast: if not set(item).issuperset(required_keys): continue - ha_item = { - k: item[v] for k, v in FORECAST_MAP.items() if item.get(v) is not None - } - if ha_item.get(ATTR_FORECAST_CONDITION): - ha_item[ATTR_FORECAST_CONDITION] = format_condition( - ha_item[ATTR_FORECAST_CONDITION] - ) - # Convert timestamp to UTC - if ha_item.get(ATTR_FORECAST_TIME): + ha_item: Forecast = cast( + Forecast, + { + k: item[v] + for k, v in FORECAST_MAP.items() + if item.get(v) is not None + }, + ) + # Convert condition + if item.get("condition"): + ha_item[ATTR_FORECAST_CONDITION] = format_condition(item["condition"]) + # Convert timestamp to UTC string + if item.get("datetime"): ha_item[ATTR_FORECAST_TIME] = dt_util.as_utc( - ha_item.get(ATTR_FORECAST_TIME) + item["datetime"] ).isoformat() ha_forecast.append(ha_item) return ha_forecast + @property + def forecast(self) -> list[Forecast]: + """Return the forecast array.""" + return self._forecast(self._hourly) + + @callback + def _async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast(False) + + @callback + def _async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast in native units.""" + return self._forecast(True) + @property def device_info(self): """Device info.""" diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index ccd23762850..6ad3868f13d 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -8,9 +8,10 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, 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 @@ -52,6 +53,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index d05c63ef684..ade6bedd362 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -7,11 +7,11 @@ from meteofrance_api.client import MeteoFranceClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback -from .const import CONF_CITY, DOMAIN, FORECAST_MODE, FORECAST_MODE_DAILY +from .const import CONF_CITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -25,14 +25,6 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Init MeteoFranceFlowHandler.""" self.places = [] - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> MeteoFranceOptionsFlowHandler: - """Get the options flow for this handler.""" - return MeteoFranceOptionsFlowHandler(config_entry) - @callback def _show_setup_form(self, user_input=None, errors=None): """Show the setup form to the user.""" @@ -114,30 +106,5 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -class MeteoFranceOptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow.""" - - 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=None): - """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_MODE, - default=self.config_entry.options.get( - CONF_MODE, FORECAST_MODE_DAILY - ), - ): vol.In(FORECAST_MODE) - } - ) - return self.async_show_form(step_id="init", data_schema=data_schema) - - def _build_place_key(place) -> str: return f"{place};{place.latitude};{place.longitude}" diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index fad1a33e25c..e950dfe1fa8 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -33,7 +33,6 @@ MANUFACTURER = "Météo-France" CONF_CITY = "city" FORECAST_MODE_HOURLY = "hourly" FORECAST_MODE_DAILY = "daily" -FORECAST_MODE = [FORECAST_MODE_HOURLY, FORECAST_MODE_DAILY] ATTR_NEXT_RAIN_1_HOUR_FORECAST = "1_hour_forecast" ATTR_NEXT_RAIN_DT_REF = "forecast_time_ref" @@ -89,3 +88,8 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 89faf6d80eb..98cb4665614 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -28,8 +28,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -141,7 +140,8 @@ SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = ( key="humidity", name="Humidity", native_unit_of_measurement=PERCENTAGE, - icon="mdi:water-percent", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, data_path="current_forecast:humidity", ), ) diff --git a/homeassistant/components/meteo_france/strings.json b/homeassistant/components/meteo_france/strings.json index 944f2b32fab..7cb7d3efe53 100644 --- a/homeassistant/components/meteo_france/strings.json +++ b/homeassistant/components/meteo_france/strings.json @@ -21,14 +21,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "options": { - "step": { - "init": { - "data": { - "mode": "Forecast mode" - } - } - } } } diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 165cefc9240..d081a6e729b 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -2,7 +2,7 @@ import logging import time -from meteofrance_api.model.forecast import Forecast +from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -13,7 +13,9 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -23,9 +25,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -35,7 +36,7 @@ from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, COORDINATOR_FORECAST, DOMAIN, FORECAST_MODE_DAILY, @@ -48,20 +49,17 @@ _LOGGER = logging.getLogger(__name__) def format_condition(condition: str): - """Return condition from dict CONDITION_CLASSES.""" - for key, value in CONDITION_CLASSES.items(): - if condition in value: - return key - return condition + """Return condition from dict CONDITION_MAP.""" + return CONDITION_MAP.get(condition, condition) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Meteo-France weather platform.""" - coordinator: DataUpdateCoordinator[Forecast] = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR_FORECAST - ] + coordinator: DataUpdateCoordinator[MeteoFranceForecast] = hass.data[DOMAIN][ + entry.entry_id + ][COORDINATOR_FORECAST] async_add_entities( [ @@ -80,7 +78,7 @@ async def async_setup_entry( class MeteoFranceWeather( - CoordinatorEntity[DataUpdateCoordinator[Forecast]], WeatherEntity + CoordinatorEntity[DataUpdateCoordinator[MeteoFranceForecast]], WeatherEntity ): """Representation of a weather condition.""" @@ -89,14 +87,28 @@ class MeteoFranceWeather( _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) - def __init__(self, coordinator: DataUpdateCoordinator[Forecast], mode: str) -> None: + def __init__( + self, coordinator: DataUpdateCoordinator[MeteoFranceForecast], mode: str + ) -> None: """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) self._city_name = self.coordinator.data.position["name"] self._mode = mode self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("daily", "hourly")) + ) + @property def unique_id(self): """Return the unique id of the sensor.""" @@ -153,12 +165,11 @@ class MeteoFranceWeather( if wind_bearing != -1: return wind_bearing - @property - def forecast(self): + def _forecast(self, mode: str) -> list[Forecast]: """Return the forecast.""" - forecast_data = [] + forecast_data: list[Forecast] = [] - if self._mode == FORECAST_MODE_HOURLY: + if mode == FORECAST_MODE_HOURLY: today = time.time() for forecast in self.coordinator.data.forecast: # Can have data in the past @@ -190,7 +201,7 @@ class MeteoFranceWeather( { ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( forecast["dt"] - ), + ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( forecast["weather12H"]["desc"] ), @@ -203,3 +214,16 @@ class MeteoFranceWeather( } ) return forecast_data + + @property + def forecast(self) -> list[Forecast]: + """Return the forecast array.""" + return self._forecast(self._mode) + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast(FORECAST_MODE_DAILY) + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast in native units.""" + return self._forecast(FORECAST_MODE_HOURLY) diff --git a/homeassistant/components/meteoclimatic/const.py b/homeassistant/components/meteoclimatic/const.py index 4de299f1cf7..4a7276d4e42 100644 --- a/homeassistant/components/meteoclimatic/const.py +++ b/homeassistant/components/meteoclimatic/const.py @@ -54,3 +54,8 @@ CONDITION_CLASSES = { ATTR_CONDITION_WINDY: [], ATTR_CONDITION_WINDY_VARIANT: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 73804c1f77a..ed37c6d98ea 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -14,8 +14,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 11346ab18f9..f9b341cf114 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -5,22 +5,20 @@ from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import ATTRIBUTION, CONDITION_CLASSES, DOMAIN, MANUFACTURER, MODEL +from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL def format_condition(condition): - """Return condition from dict CONDITION_CLASSES.""" - for key, value in CONDITION_CLASSES.items(): - if condition in value: - return key + """Return condition from dict CONDITION_MAP.""" + if condition in CONDITION_MAP: + return CONDITION_MAP[condition] if isinstance(condition, Condition): return condition.value return condition diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 695c6c8f47d..56bf5ee99ce 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( diff --git a/homeassistant/components/metoffice/const.py b/homeassistant/components/metoffice/const.py index e4843d1235e..8b86784b70b 100644 --- a/homeassistant/components/metoffice/const.py +++ b/homeassistant/components/metoffice/const.py @@ -52,6 +52,11 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} VISIBILITY_CLASSES = { "VP": "Very Poor", diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index fcb8e5b134e..371c396a829 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -29,7 +29,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_device_info from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, METOFFICE_COORDINATES, METOFFICE_DAILY_COORDINATOR, @@ -221,11 +221,7 @@ class MetOfficeCurrentSensor( elif self.entity_description.key == "weather" and hasattr( self.coordinator.data.now, self.entity_description.key ): - value = [ - k - for k, v in CONDITION_CLASSES.items() - if self.coordinator.data.now.weather.value in v - ][0] + value = CONDITION_MAP.get(self.coordinator.data.now.weather.value) elif hasattr(self.coordinator.data.now, self.entity_description.key): value = getattr(self.coordinator.data.now, self.entity_description.key) diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 8257c8a3c35..0b4672ddec8 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -26,7 +26,7 @@ from homeassistant.helpers.update_coordinator import ( from . import get_device_info from .const import ( ATTRIBUTION, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, METOFFICE_COORDINATES, METOFFICE_DAILY_COORDINATOR, @@ -55,7 +55,7 @@ async def async_setup_entry( def _build_forecast_data(timestep: Timestep) -> Forecast: data = Forecast(datetime=timestep.date.isoformat()) if timestep.weather: - data[ATTR_FORECAST_CONDITION] = _get_weather_condition(timestep.weather.value) + data[ATTR_FORECAST_CONDITION] = CONDITION_MAP.get(timestep.weather.value) if timestep.precipitation: data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = timestep.precipitation.value if timestep.temperature: @@ -67,13 +67,6 @@ def _build_forecast_data(timestep: Timestep) -> Forecast: return data -def _get_weather_condition(metoffice_code: str) -> str | None: - for hass_name, metoffice_codes in CONDITION_CLASSES.items(): - if metoffice_code in metoffice_codes: - return hass_name - return None - - class MetOfficeWeather( CoordinatorEntity[DataUpdateCoordinator[MetOfficeData]], WeatherEntity ): @@ -107,7 +100,7 @@ class MetOfficeWeather( def condition(self) -> str | None: """Return the current condition.""" if self.coordinator.data.now: - return _get_weather_condition(self.coordinator.data.now.weather.value) + return CONDITION_MAP.get(self.coordinator.data.now.weather.value) return None @property diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index f57a9146858..6e47ad79f5b 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -7,7 +7,6 @@ import logging import aiohttp from aiohttp.hdrs import CONTENT_TYPE -import async_timeout import voluptuous as vol from homeassistant.components import camera @@ -314,7 +313,7 @@ class MicrosoftFace: payload = None try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): response = await getattr(self.websession, method)( url, data=payload, headers=headers, params=params ) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 8fd1d1a3e22..0482e573766 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -73,8 +73,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=update_interval, ) - hass.data[DOMAIN][conn_type][key] = data_coordinator await data_coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][conn_type][key] = data_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index f1487ed59f1..2ddcf97f25a 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -5,8 +5,6 @@ import mill import voluptuous as vol from homeassistant.components.climate import ( - FAN_OFF, - FAN_ON, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -17,14 +15,12 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_IP_ADDRESS, CONF_USERNAME, - PRECISION_HALVES, - PRECISION_WHOLE, + PRECISION_TENTHS, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -90,11 +86,13 @@ async def async_setup_entry( class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): """Representation of a Mill Thermostat device.""" - _attr_fan_modes = [FAN_ON, FAN_OFF] _attr_has_entity_name = True + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__( @@ -111,22 +109,9 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, heater.device_id)}, manufacturer=MANUFACTURER, - model=f"Generation {heater.generation}", + model=heater.model, name=heater.name, ) - if heater.is_gen1: - self._attr_hvac_modes = [HVACMode.HEAT] - else: - self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - - if heater.generation < 3: - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - self._attr_target_temperature_step = PRECISION_WHOLE - else: - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - self._attr_target_temperature_step = PRECISION_HALVES self._update_attr(heater) @@ -139,26 +124,16 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): ) await self.coordinator.async_request_refresh() - async def async_set_fan_mode(self, fan_mode: str) -> None: - """Set new target fan mode.""" - fan_status = 1 if fan_mode == FAN_ON else 0 - await self.coordinator.mill_data_connection.heater_control( - self._id, fan_status=fan_status - ) - await self.coordinator.async_request_refresh() - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - heater = self.coordinator.data[self._id] - if hvac_mode == HVACMode.HEAT: await self.coordinator.mill_data_connection.heater_control( - self._id, power_status=1 + self._id, power_status=True ) await self.coordinator.async_request_refresh() - elif hvac_mode == HVACMode.OFF and not heater.is_gen1: + elif hvac_mode == HVACMode.OFF: await self.coordinator.mill_data_connection.heater_control( - self._id, power_status=0 + self._id, power_status=False ) await self.coordinator.async_request_refresh() @@ -178,23 +153,20 @@ class MillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntity): self._available = heater.available self._attr_extra_state_attributes = { "open_window": heater.open_window, - "heating": heater.is_heating, "controlled_by_tibber": heater.tibber_control, - "heater_generation": heater.generation, } - if heater.room: - self._attr_extra_state_attributes["room"] = heater.room.name - self._attr_extra_state_attributes["avg_room_temp"] = heater.room.avg_temp + if heater.room_name: + self._attr_extra_state_attributes["room"] = heater.room_name + self._attr_extra_state_attributes["avg_room_temp"] = heater.room_avg_temp else: self._attr_extra_state_attributes["room"] = "Independent device" self._attr_target_temperature = heater.set_temp self._attr_current_temperature = heater.current_temp - self._attr_fan_mode = FAN_ON if heater.fan_status == 1 else HVACMode.OFF - if heater.is_heating == 1: + if heater.is_heating: self._attr_hvac_action = HVACAction.HEATING else: self._attr_hvac_action = HVACAction.IDLE - if heater.is_gen1 or heater.power_status == 1: + if heater.power_status: self._attr_hvac_mode = HVACMode.HEAT else: self._attr_hvac_mode = HVACMode.OFF @@ -210,7 +182,7 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit _attr_min_temp = MIN_TEMP _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - _attr_target_temperature_step = PRECISION_HALVES + _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 0666c1107ca..b2dbf993dae 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.10.0", "mill-local==0.2.0"] + "requirements": ["millheater==0.11.1", "mill-local==0.2.0"] } diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index a915418bb93..47b5b8c7b64 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -22,8 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -172,13 +171,10 @@ class MillSensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mill_device.device_id)}, - name=self.name, + name=mill_device.name, manufacturer=MANUFACTURER, + model=mill_device.model, ) - if isinstance(mill_device, mill.Heater): - self._attr_device_info["model"] = f"Generation {mill_device.generation}" - elif isinstance(mill_device, mill.Sensor): - self._attr_device_info["model"] = "Mill Sense Air" self._update_attr(mill_device) @callback diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index aef6c94767f..cf0d96af8d2 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any @@ -62,6 +63,19 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok +@dataclass +class MinecraftServerData: + """Representation of Minecraft server data.""" + + latency: float | None = None + motd: str | None = None + players_max: int | None = None + players_online: int | None = None + players_list: list[str] | None = None + protocol_version: int | None = None + version: str | None = None + + class MinecraftServer: """Representation of a Minecraft server.""" @@ -84,13 +98,7 @@ class MinecraftServer: self._server = JavaServer(self.host, self.port) # Data provided by 3rd party library - self.version: str | None = None - self.protocol_version: int | None = None - self.latency_time: float | None = None - self.players_online: int | None = None - self.players_max: int | None = None - self.players_list: list[str] | None = None - self.motd: str | None = None + self.data: MinecraftServerData = MinecraftServerData() # Dispatcher signal name self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" @@ -170,18 +178,18 @@ class MinecraftServer: status_response = await self._server.async_status() # Got answer to request, update properties. - self.version = status_response.version.name - self.protocol_version = status_response.version.protocol - self.players_online = status_response.players.online - self.players_max = status_response.players.max - self.latency_time = status_response.latency - self.motd = status_response.motd.to_plain() + self.data.version = status_response.version.name + self.data.protocol_version = status_response.version.protocol + self.data.players_online = status_response.players.online + self.data.players_max = status_response.players.max + self.data.latency = status_response.latency + self.data.motd = status_response.motd.to_plain() - self.players_list = [] + self.data.players_list = [] if status_response.players.sample is not None: for player in status_response.players.sample: - self.players_list.append(player.name) - self.players_list.sort() + self.data.players_list.append(player.name) + self.data.players_list.sort() # Inform user once about successful update if necessary. if self._last_status_request_failed: @@ -193,13 +201,13 @@ class MinecraftServer: self._last_status_request_failed = False except OSError as error: # No answer to request, set all properties to unknown. - self.version = None - self.protocol_version = None - self.players_online = None - self.players_max = None - self.latency_time = None - self.players_list = None - self.motd = None + self.data.version = None + self.data.protocol_version = None + self.data.players_online = None + self.data.players_max = None + self.data.latency = None + self.data.players_list = None + self.data.motd = None # Inform user once about failed update if necessary. if not self._last_status_request_failed: diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 5c9cb5f42e1..3589bfab3e2 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinecraftServer -from .const import DOMAIN, ICON_STATUS, NAME_STATUS +from .const import DOMAIN, ICON_STATUS, KEY_STATUS, NAME_STATUS from .entity import MinecraftServerEntity @@ -30,7 +30,7 @@ async def async_setup_entry( class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorEntity): """Representation of a Minecraft Server status binary sensor.""" - _attr_translation_key = "status" + _attr_translation_key = KEY_STATUS def __init__(self, server: MinecraftServer) -> None: """Initialize status binary sensor.""" diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index b402b7cfff0..c8429284cd8 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -30,7 +30,7 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): address_left, separator, address_right = user_input[CONF_HOST].rpartition( ":" ) - # If no separator is found, 'rpartition' return ('', '', original_string). + # If no separator is found, 'rpartition' returns ('', '', original_string). if separator == "": host = address_right else: diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 8fe7c9b2791..72a891138c4 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -8,7 +8,7 @@ DEFAULT_PORT = 25565 DOMAIN = "minecraft_server" -ICON_LATENCY_TIME = "mdi:signal" +ICON_LATENCY = "mdi:signal" ICON_PLAYERS_MAX = "mdi:account-multiple" ICON_PLAYERS_ONLINE = "mdi:account-multiple" ICON_PROTOCOL_VERSION = "mdi:numeric" @@ -16,11 +16,17 @@ ICON_STATUS = "mdi:lan" ICON_VERSION = "mdi:numeric" ICON_MOTD = "mdi:minecraft" -KEY_SERVERS = "servers" +KEY_LATENCY = "latency" +KEY_PLAYERS_MAX = "players_max" +KEY_PLAYERS_ONLINE = "players_online" +KEY_PROTOCOL_VERSION = "protocol_version" +KEY_STATUS = "status" +KEY_VERSION = "version" +KEY_MOTD = "motd" MANUFACTURER = "Mojang AB" -NAME_LATENCY_TIME = "Latency Time" +NAME_LATENCY = "Latency Time" NAME_PLAYERS_MAX = "Players Max" NAME_PLAYERS_ONLINE = "Players Online" NAME_PROTOCOL_VERSION = "Protocol Version" diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 02875cb69f2..63d68d0aa77 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,8 +1,9 @@ """Base entity for the Minecraft Server integration.""" from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from . import MinecraftServer from .const import DOMAIN, MANUFACTURER @@ -28,9 +29,9 @@ class MinecraftServerEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._server.unique_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({self._server.version})", + model=f"Minecraft Server ({self._server.data.version})", name=self._server.name, - sw_version=str(self._server.protocol_version), + sw_version=f"{self._server.data.protocol_version}", ) self._attr_device_class = device_class self._extra_state_attributes = None diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 3a9e4b8f0a0..74422675718 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -11,13 +11,19 @@ from . import MinecraftServer from .const import ( ATTR_PLAYERS_LIST, DOMAIN, - ICON_LATENCY_TIME, + ICON_LATENCY, ICON_MOTD, ICON_PLAYERS_MAX, ICON_PLAYERS_ONLINE, ICON_PROTOCOL_VERSION, ICON_VERSION, - NAME_LATENCY_TIME, + KEY_LATENCY, + KEY_MOTD, + KEY_PLAYERS_MAX, + KEY_PLAYERS_ONLINE, + KEY_PROTOCOL_VERSION, + KEY_VERSION, + NAME_LATENCY, NAME_MOTD, NAME_PLAYERS_MAX, NAME_PLAYERS_ONLINE, @@ -41,7 +47,7 @@ async def async_setup_entry( entities = [ MinecraftServerVersionSensor(server), MinecraftServerProtocolVersionSensor(server), - MinecraftServerLatencyTimeSensor(server), + MinecraftServerLatencySensor(server), MinecraftServerPlayersOnlineSensor(server), MinecraftServerPlayersMaxSensor(server), MinecraftServerMOTDSensor(server), @@ -75,7 +81,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): class MinecraftServerVersionSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server version sensor.""" - _attr_translation_key = "version" + _attr_translation_key = KEY_VERSION def __init__(self, server: MinecraftServer) -> None: """Initialize version sensor.""" @@ -83,13 +89,13 @@ class MinecraftServerVersionSensor(MinecraftServerSensorEntity): async def async_update(self) -> None: """Update version.""" - self._attr_native_value = self._server.version + self._attr_native_value = self._server.data.version class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server protocol version sensor.""" - _attr_translation_key = "protocol_version" + _attr_translation_key = KEY_PROTOCOL_VERSION def __init__(self, server: MinecraftServer) -> None: """Initialize protocol version sensor.""" @@ -101,32 +107,32 @@ class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): async def async_update(self) -> None: """Update protocol version.""" - self._attr_native_value = self._server.protocol_version + self._attr_native_value = self._server.data.protocol_version -class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server latency time sensor.""" +class MinecraftServerLatencySensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server latency sensor.""" - _attr_translation_key = "latency" + _attr_translation_key = KEY_LATENCY def __init__(self, server: MinecraftServer) -> None: - """Initialize latency time sensor.""" + """Initialize latency sensor.""" super().__init__( server=server, - type_name=NAME_LATENCY_TIME, - icon=ICON_LATENCY_TIME, + type_name=NAME_LATENCY, + icon=ICON_LATENCY, unit=UnitOfTime.MILLISECONDS, ) async def async_update(self) -> None: - """Update latency time.""" - self._attr_native_value = self._server.latency_time + """Update latency.""" + self._attr_native_value = self._server.data.latency class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server online players sensor.""" - _attr_translation_key = "players_online" + _attr_translation_key = KEY_PLAYERS_ONLINE def __init__(self, server: MinecraftServer) -> None: """Initialize online players sensor.""" @@ -139,13 +145,13 @@ class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): async def async_update(self) -> None: """Update online players state and device state attributes.""" - self._attr_native_value = self._server.players_online + self._attr_native_value = self._server.data.players_online extra_state_attributes = {} - players_list = self._server.players_list + players_list = self._server.data.players_list if players_list is not None and len(players_list) != 0: - extra_state_attributes[ATTR_PLAYERS_LIST] = self._server.players_list + extra_state_attributes[ATTR_PLAYERS_LIST] = players_list self._attr_extra_state_attributes = extra_state_attributes @@ -153,7 +159,7 @@ class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server maximum number of players sensor.""" - _attr_translation_key = "players_max" + _attr_translation_key = KEY_PLAYERS_MAX def __init__(self, server: MinecraftServer) -> None: """Initialize maximum number of players sensor.""" @@ -166,13 +172,13 @@ class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): async def async_update(self) -> None: """Update maximum number of players.""" - self._attr_native_value = self._server.players_max + self._attr_native_value = self._server.data.players_max class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server MOTD sensor.""" - _attr_translation_key = "motd" + _attr_translation_key = KEY_MOTD def __init__(self, server: MinecraftServer) -> None: """Initialize MOTD sensor.""" @@ -184,4 +190,4 @@ class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): async def async_update(self) -> None: """Update MOTD.""" - self._attr_native_value = self._server.motd + self._attr_native_value = self._server.data.motd diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index c2ab3b5768c..a2b2de4eda8 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -7,7 +7,6 @@ from contextlib import suppress import aiohttp from aiohttp import web -import async_timeout import httpx from yarl import URL @@ -26,7 +25,7 @@ from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_web, async_get_clientsession, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client @@ -144,7 +143,7 @@ class MjpegCamera(Camera): websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): response = await websession.get(self._still_image_url, auth=self._auth) image = await response.read() @@ -206,7 +205,7 @@ class MjpegCamera(Camera): async for chunk in stream.aiter_bytes(BUFFER_SIZE): if not self.hass.is_running: break - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): await response.write(chunk) return response diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index d347a0cc4db..fb555db49cb 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -1,4 +1,4 @@ -"""Device tracker platform that adds support for OwnTracks over MQTT.""" +"""Device tracker for Mobile app.""" from homeassistant.components.device_tracker import ( ATTR_BATTERY, ATTR_GPS, @@ -35,7 +35,7 @@ ATTR_KEYS = (ATTR_ALTITUDE, ATTR_COURSE, ATTR_SPEED, ATTR_VERTICAL_ACCURACY) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up OwnTracks based off an entry.""" + """Set up Mobile app based off an entry.""" entity = MobileAppEntity(entry) async_add_entities([entity]) @@ -44,7 +44,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" def __init__(self, entry, data=None): - """Set up OwnTracks entity.""" + """Set up Mobile app entity.""" self._entry = entry self._data = data self._dispatch_unsub = None diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 3a2f038a0af..120014d1d52 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -1,6 +1,8 @@ """A entity class for mobile_app.""" from __future__ import annotations +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 @@ -36,7 +38,9 @@ class MobileAppEntity(RestoreEntity): """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( - self.hass, SIGNAL_SENSOR_UPDATE, self._handle_update + self.hass, + f"{SIGNAL_SENSOR_UPDATE}-{self._attr_unique_id}", + self._handle_update, ) ) @@ -96,10 +100,7 @@ class MobileAppEntity(RestoreEntity): return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE @callback - def _handle_update(self, incoming_id, data): + def _handle_update(self, data: dict[str, Any]) -> None: """Handle async event updates.""" - if incoming_id != self._attr_unique_id: - return - - self._config = {**self._config, **data} + self._config.update(data) self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index f14223f4a04..e8460b721a2 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -13,7 +13,7 @@ from nacl.secret import SecretBox from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.json import JSONEncoder from homeassistant.util.json import JsonValueType, json_loads @@ -144,7 +144,7 @@ def error_response( def supports_encryption() -> bool: """Test if we support encryption.""" try: - import nacl # noqa: F401 pylint: disable=unused-import, import-outside-toplevel + import nacl # noqa: F401 pylint: disable=import-outside-toplevel return True except OSError: diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index dc9f8aaedcd..164f21af15a 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -7,7 +7,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout from homeassistant.components.notify import ( ATTR_DATA, @@ -60,7 +59,6 @@ def push_registrations(hass): return targets -# pylint: disable=invalid-name def log_rate_limits(hass, device_name, resp, level=logging.INFO): """Output rate limit log line at given level.""" if ATTR_PUSH_RATE_LIMITS not in resp: @@ -166,7 +164,7 @@ class MobileAppNotificationService(BaseNotificationService): target_data["registration_info"] = reg_info try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await async_get_clientsession(self._hass).post( push_url, json=target_data ) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 62417b0873a..1a56b13ddc5 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -607,7 +607,7 @@ async def webhook_register_sensor( if changes: entity_registry.async_update_entity(existing_sensor, **changes) - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, unique_store_key, data) + async_dispatcher_send(hass, f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", data) else: data[CONF_UNIQUE_ID] = unique_store_key data[ @@ -693,8 +693,7 @@ async def webhook_update_sensor_states( sensor[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID] async_dispatcher_send( hass, - SIGNAL_SENSOR_UPDATE, - unique_store_key, + f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", sensor, ) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index e8c53469769..cb36661d711 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -55,7 +55,6 @@ from .const import ( # noqa: F401 CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, - CALL_TYPE_WRITE_REGISTER, CALL_TYPE_X_COILS, CALL_TYPE_X_REGISTER_HOLDINGS, CONF_BAUDRATE, @@ -64,7 +63,6 @@ from .const import ( # noqa: F401 CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_FANS, - CONF_HUB, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -82,6 +80,7 @@ from .const import ( # noqa: F401 CONF_MIN_TEMP, CONF_MIN_VALUE, CONF_MSG_WAIT, + CONF_NAN_VALUE, CONF_PARITY, CONF_PRECISION, CONF_RETRIES, @@ -104,6 +103,7 @@ from .const import ( # noqa: F401 CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, + CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_VERIFY, CONF_WRITE_REGISTERS, CONF_WRITE_TYPE, @@ -122,6 +122,7 @@ from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, duplicate_modbus_validator, + nan_validator, number_validator, scan_interval_validator, struct_validator, @@ -228,6 +229,7 @@ CLIMATE_SCHEMA = vol.All( BASE_STRUCT_SCHEMA.extend( { 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): cv.positive_int, vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), @@ -238,13 +240,27 @@ CLIMATE_SCHEMA = vol.All( { CONF_ADDRESS: cv.positive_int, CONF_HVAC_MODE_VALUES: { - vol.Optional(CONF_HVAC_MODE_OFF): cv.positive_int, - vol.Optional(CONF_HVAC_MODE_HEAT): cv.positive_int, - vol.Optional(CONF_HVAC_MODE_COOL): cv.positive_int, - vol.Optional(CONF_HVAC_MODE_HEAT_COOL): cv.positive_int, - vol.Optional(CONF_HVAC_MODE_AUTO): cv.positive_int, - vol.Optional(CONF_HVAC_MODE_DRY): cv.positive_int, - vol.Optional(CONF_HVAC_MODE_FAN_ONLY): cv.positive_int, + vol.Optional(CONF_HVAC_MODE_OFF): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_MODE_HEAT): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_MODE_COOL): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_MODE_HEAT_COOL): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_MODE_AUTO): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_MODE_DRY): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_MODE_FAN_ONLY): vol.Any( + cv.positive_int, [cv.positive_int] + ), }, vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } @@ -296,6 +312,7 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, vol.Optional(CONF_MIN_VALUE): number_validator, vol.Optional(CONF_MAX_VALUE): number_validator, + vol.Optional(CONF_NAN_VALUE): nan_validator, vol.Optional(CONF_ZERO_SUPPRESS): number_validator, } ), diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index bd21e12368e..65cfa1b49ba 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_SLAVE, CONF_STRUCTURE, CONF_UNIQUE_ID, + STATE_OFF, STATE_ON, ) from homeassistant.core import callback @@ -46,6 +47,7 @@ from .const import ( CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, + CONF_NAN_VALUE, CONF_PRECISION, CONF_SCALE, CONF_SLAVE_COUNT, @@ -75,10 +77,6 @@ class BasePlatform(Entity): def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: """Initialize the Modbus binary sensor.""" self._hub = hub - # temporary fix, - # make sure slave is always defined to avoid an error in pymodbus - # attr(in_waiting) not defined. - # see issue #657 and PR #660 in riptideio/pymodbus self._slave = entry.get(CONF_SLAVE, 0) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] @@ -107,6 +105,7 @@ class BasePlatform(Entity): self._min_value = get_optional_numeric_config(CONF_MIN_VALUE) self._max_value = get_optional_numeric_config(CONF_MAX_VALUE) + self._nan_value = entry.get(CONF_NAN_VALUE, None) self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS) @abstractmethod @@ -189,8 +188,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers.reverse() return registers - def __process_raw_value(self, entry: float | int) -> float | int: - """Process value from sensor with scaling, offset, min/max etc.""" + def __process_raw_value(self, entry: float | int | str) -> float | int | str | None: + """Process value from sensor with NaN handling, scaling, offset, min/max etc.""" + if self._nan_value and entry in (self._nan_value, -self._nan_value): + return None val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: return self._min_value @@ -230,6 +231,11 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): # the conversion only when it's absolutely necessary. if isinstance(v_temp, int) and self._precision == 0: v_result.append(str(v_temp)) + elif v_temp is None: + v_result.append("") # pragma: no cover + elif v_temp != v_temp: # noqa: PLR0124 + # NaN float detection replace with None + v_result.append("nan") # pragma: no cover else: v_result.append(f"{float(v_temp):.{self._precision}f}") return ",".join(map(str, v_result)) @@ -240,8 +246,18 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): # We could convert int to float, and the code would still work; however # we lose some precision, and unit tests will fail. Therefore, we do # the conversion only when it's absolutely necessary. + + if val_result is None: + return None + # NaN float detection replace with None + if val_result != val_result: # noqa: PLR0124 + return None # pragma: no cover if isinstance(val_result, int) and self._precision == 0: return str(val_result) + if isinstance(val_result, str): + if val_result == "nan": + val_result = None # pragma: no cover + return val_result return f"{float(val_result):.{self._precision}f}" @@ -296,11 +312,14 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Handle entity which will be added.""" await self.async_base_added_to_hass() if state := await self.async_get_last_state(): - self._attr_is_on = state.state == STATE_ON + if state.state == STATE_ON: + self._attr_is_on = True + elif state.state == STATE_OFF: + self._attr_is_on = False async def async_turn(self, command: int) -> None: """Evaluate switch result.""" - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, self._address, command, self._write_type ) if result is None: @@ -336,7 +355,7 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): if self._call_active: return self._call_active = True - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, self._verify_address, 1, self._verify_type ) self._call_active = False diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 43f43585775..05668bac0a9 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -97,7 +97,7 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): if self._call_active: return self._call_active = True - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type ) self._call_active = False diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 3e5155b574c..3acf8d7ac29 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -45,6 +45,7 @@ from .const import ( CONF_MIN_TEMP, CONF_STEP, CONF_TARGET_TEMP, + CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, DataType, ) @@ -84,6 +85,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Initialize the modbus thermostat.""" super().__init__(hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] + self._target_temperature_write_registers = config[ + CONF_TARGET_TEMP_WRITE_REGISTERS + ] self._unit = config[CONF_TEMPERATURE_UNIT] self._attr_current_temperature = None @@ -107,7 +111,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_modes = cast(list[HVACMode], []) self._attr_hvac_mode = None self._hvac_mode_mapping: list[tuple[int, HVACMode]] = [] - self._hvac_mode_write_type = mode_config[CONF_WRITE_REGISTERS] + self._hvac_mode_write_registers = mode_config[CONF_WRITE_REGISTERS] mode_value_config = mode_config[CONF_HVAC_MODE_VALUES] for hvac_mode_kw, hvac_mode in ( @@ -120,9 +124,11 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): (CONF_HVAC_MODE_FAN_ONLY, HVACMode.FAN_ONLY), ): if hvac_mode_kw in mode_value_config: - self._hvac_mode_mapping.append( - (mode_value_config[hvac_mode_kw], hvac_mode) - ) + values = mode_value_config[hvac_mode_kw] + if not isinstance(values, list): + values = [values] + for value in values: + self._hvac_mode_mapping.append((value, hvac_mode)) self._attr_hvac_modes.append(hvac_mode) else: @@ -133,7 +139,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] - self._hvac_onoff_write_type = config[CONF_WRITE_REGISTERS] + self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] if HVACMode.OFF not in self._attr_hvac_modes: self._attr_hvac_modes.append(HVACMode.OFF) else: @@ -150,15 +156,15 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Set new target hvac mode.""" if self._hvac_onoff_register is not None: # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise. - if self._hvac_onoff_write_type: - await self._hub.async_pymodbus_call( + if self._hvac_onoff_write_registers: + await self._hub.async_pb_call( self._slave, self._hvac_onoff_register, [0 if hvac_mode == HVACMode.OFF else 1], CALL_TYPE_WRITE_REGISTERS, ) else: - await self._hub.async_pymodbus_call( + await self._hub.async_pb_call( self._slave, self._hvac_onoff_register, 0 if hvac_mode == HVACMode.OFF else 1, @@ -169,15 +175,15 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # Write a value to the mode register for the desired mode. for value, mode in self._hvac_mode_mapping: if mode == hvac_mode: - if self._hvac_mode_write_type: - await self._hub.async_pymodbus_call( + if self._hvac_mode_write_registers: + await self._hub.async_pb_call( self._slave, self._hvac_mode_register, [value], CALL_TYPE_WRITE_REGISTERS, ) else: - await self._hub.async_pymodbus_call( + await self._hub.async_pb_call( self._slave, self._hvac_mode_register, value, @@ -212,14 +218,22 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): DataType.INT16, DataType.UINT16, ): - result = await self._hub.async_pymodbus_call( - self._slave, - self._target_temperature_register, - int(float(registers[0])), - CALL_TYPE_WRITE_REGISTER, - ) + if self._target_temperature_write_registers: + result = await self._hub.async_pb_call( + self._slave, + self._target_temperature_register, + [int(float(registers[0]))], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + result = await self._hub.async_pb_call( + self._slave, + self._target_temperature_register, + int(float(registers[0])), + CALL_TYPE_WRITE_REGISTER, + ) else: - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, self._target_temperature_register, [int(float(i)) for i in registers], @@ -275,7 +289,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self, register_type: str, register: int, raw: bool | None = False ) -> float | None: """Read register using the Modbus hub slave.""" - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, register, self._count, register_type ) if result is None: diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 4191e1df56f..e509577267c 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -16,13 +16,8 @@ CONF_BAUDRATE = "baudrate" CONF_BYTESIZE = "bytesize" CONF_CLIMATES = "climates" CONF_CLOSE_COMM_ON_ERROR = "close_comm_on_error" -CONF_COILS = "coils" -CONF_CURRENT_TEMP = "current_temp_register" -CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" CONF_DATA_TYPE = "data_type" CONF_FANS = "fans" -CONF_HUB = "hub" -CONF_INPUTS = "inputs" CONF_INPUT_TYPE = "input_type" CONF_LAZY_ERROR = "lazy_error_count" CONF_MAX_TEMP = "max_temp" @@ -30,10 +25,8 @@ CONF_MAX_VALUE = "max_value" CONF_MIN_TEMP = "min_temp" CONF_MIN_VALUE = "min_value" CONF_MSG_WAIT = "message_wait_milliseconds" +CONF_NAN_VALUE = "nan_value" CONF_PARITY = "parity" -CONF_REGISTER = "register" -CONF_REGISTER_TYPE = "register_type" -CONF_REGISTERS = "registers" CONF_RETRIES = "retries" CONF_RETRY_ON_EMPTY = "retry_on_empty" CONF_PRECISION = "precision" @@ -55,6 +48,7 @@ CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" +CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" CONF_HVAC_MODE_VALUES = "values" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" @@ -67,8 +61,6 @@ CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" -CONF_VERIFY_REGISTER = "verify_register" -CONF_VERIFY_STATE = "verify_state" CONF_WRITE_TYPE = "write_type" CONF_ZERO_SUPPRESS = "zero_suppress" @@ -80,7 +72,7 @@ UDP = "udp" # service call attributes ATTR_ADDRESS = CONF_ADDRESS -ATTR_HUB = CONF_HUB +ATTR_HUB = "hub" ATTR_UNIT = "unit" ATTR_SLAVE = "slave" ATTR_VALUE = "value" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 62344f470c4..3c4247c61fb 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -120,7 +120,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, self._write_address, self._state_open, self._write_type ) self._attr_available = result is not None @@ -128,7 +128,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, self._write_address, self._state_closed, self._write_type ) self._attr_available = result is not None @@ -142,7 +142,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): if self._call_active: return self._call_active = True - result = await self._hub.async_pymodbus_call( + result = await self._hub.async_pb_call( self._slave, self._address, 1, self._input_type ) self._call_active = False diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index c2e6b9ef467..d0d573227d8 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.3.1"] + "requirements": ["pymodbus==3.4.1"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index cb3501f3375..fdb7be3d3cf 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -13,7 +13,6 @@ from pymodbus.client import ( ModbusTcpClient, ModbusUdpClient, ) -from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer @@ -80,7 +79,7 @@ _LOGGER = logging.getLogger(__name__) ConfEntry = namedtuple("ConfEntry", "call_type attr func_name") RunEntry = namedtuple("RunEntry", "attr func") -PYMODBUS_CALL = [ +PB_CALL = [ ConfEntry( CALL_TYPE_COIL, "bits", @@ -179,11 +178,11 @@ async def async_modbus_setup( service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB ] if isinstance(value, list): - await hub.async_pymodbus_call( + await hub.async_pb_call( unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS ) else: - await hub.async_pymodbus_call( + await hub.async_pb_call( unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER ) @@ -200,9 +199,9 @@ async def async_modbus_setup( service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB ] if isinstance(state, list): - await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COILS) + await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COILS) else: - await hub.async_pymodbus_call(unit, address, state, CALL_TYPE_WRITE_COIL) + await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COIL) for x_write in ( (SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int), @@ -265,7 +264,7 @@ class ModbusHub: self.name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] - self._pb_call: dict[str, RunEntry] = {} + self._pb_request: dict[str, RunEntry] = {} self._pb_class = { SERIAL: ModbusSerialClient, TCP: ModbusTcpClient, @@ -301,7 +300,6 @@ class ModbusHub: else: self._pb_params["framer"] = ModbusSocketFramer - Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: self._msg_wait = client_config[CONF_MSG_WAIT] / 1000 elif self._config_type == SERIAL: @@ -317,10 +315,10 @@ class ModbusHub: _LOGGER.error(log_text) self._in_error = error_state - async def async_pymodbus_connect(self) -> None: + async def async_pb_connect(self) -> None: """Connect to device, async.""" async with self._lock: - if not await self.hass.async_add_executor_job(self._pymodbus_connect): + if not await self.hass.async_add_executor_job(self.pb_connect): err = f"{self.name} connect failed, retry in pymodbus" self._log_error(err, error_state=False) @@ -332,12 +330,12 @@ class ModbusHub: self._log_error(str(exception_error), error_state=False) return False - for entry in PYMODBUS_CALL: + for entry in PB_CALL: func = getattr(self._client, entry.func_name) - self._pb_call[entry.call_type] = RunEntry(entry.attr, func) + self._pb_request[entry.call_type] = RunEntry(entry.attr, func) self.hass.async_create_background_task( - self.async_pymodbus_connect(), "modbus-connect" + self.async_pb_connect(), "modbus-connect" ) # Start counting down to allow modbus requests. @@ -376,7 +374,7 @@ class ModbusHub: message = f"modbus {self.name} communication closed" _LOGGER.warning(message) - def _pymodbus_connect(self) -> bool: + def pb_connect(self) -> bool: """Connect client.""" try: self._client.connect() # type: ignore[union-attr] @@ -388,12 +386,12 @@ class ModbusHub: _LOGGER.info(message) return True - def _pymodbus_call( + def pb_call( self, unit: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusResponse | None: """Call sync. pymodbus.""" kwargs = {"slave": unit} if unit else {} - entry = self._pb_call[use_call] + entry = self._pb_request[use_call] try: result: ModbusResponse = entry.func(address, value, **kwargs) except ModbusException as exception_error: @@ -405,7 +403,7 @@ class ModbusHub: self._in_error = False return result - async def async_pymodbus_call( + async def async_pb_call( self, unit: int | None, address: int, @@ -419,7 +417,7 @@ class ModbusHub: if not self._client: return None result = await self.hass.async_add_executor_job( - self._pymodbus_call, unit, address, value, use_call + self.pb_call, unit, address, value, use_call ) if self._msg_wait: # small delay until next request/response diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 76d8c4e0b5a..fe2d4bc415d 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_SENSORS, CONF_UNIQUE_ID, @@ -72,6 +73,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = entry.get(CONF_STATE_CLASS) + self._attr_device_class = entry.get(CONF_DEVICE_CLASS) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -104,7 +106,7 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval - raw_result = await self._hub.async_pymodbus_call( + raw_result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type ) if raw_result is None: @@ -157,6 +159,8 @@ class SlaveSensor( self._attr_unique_id = entry.get(CONF_UNIQUE_ID) if self._attr_unique_id: self._attr_unique_id = f"{self._attr_unique_id}_{idx}" + self._attr_native_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_state_class = entry.get(CONF_STATE_CLASS) self._attr_available = False super().__init__(coordinator) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 4f3f4a1c8a1..f5f88ea5f59 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -155,6 +155,20 @@ def number_validator(value: Any) -> int | float: 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): + return value + try: + return int(value) + except (TypeError, ValueError): + pass + try: + return int(value, 16) + except (TypeError, ValueError) as err: + raise vol.Invalid(f"invalid number {value}") from err + + def scan_interval_validator(config: dict) -> dict: """Control scan_interval.""" for hub in config: diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 4b149deece3..5f9e4cf489c 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -7,6 +7,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_KEY_API, DOMAIN @@ -32,13 +33,15 @@ class PhoneModemButton(ButtonEntity): """Implementation of USB modem caller ID button.""" _attr_icon = "mdi:phone-hangup" - _attr_name = "Phone Modem Reject" + _attr_translation_key = "phone_modem_reject" + _attr_has_entity_name = True def __init__(self, api: PhoneModem, device: str, server_unique_id: str) -> None: """Initialize the button.""" self.device = device self.api = api self._attr_unique_id = server_unique_id + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, server_unique_id)}) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 1cb1043a5e0..c7c4403300a 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CID, DATA_KEY_API, DOMAIN, ICON @@ -21,7 +22,6 @@ async def async_setup_entry( [ ModemCalleridSensor( api, - entry.title, entry.entry_id, ) ] @@ -42,11 +42,12 @@ class ModemCalleridSensor(SensorEntity): _attr_icon = ICON _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None - def __init__(self, api: PhoneModem, name: str, server_unique_id: str) -> None: + def __init__(self, api: PhoneModem, server_unique_id: str) -> None: """Initialize the sensor.""" self.api = api - self._attr_name = name self._attr_unique_id = server_unique_id self._attr_native_value = STATE_IDLE self._attr_extra_state_attributes = { @@ -54,6 +55,7 @@ class ModemCalleridSensor(SensorEntity): CID.CID_NUMBER: "", CID.CID_NAME: "", } + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, server_unique_id)}) async def async_added_to_hass(self) -> None: """Call when the modem sensor is added to Home Assistant.""" diff --git a/homeassistant/components/modem_callerid/strings.json b/homeassistant/components/modem_callerid/strings.json index 2e18ba3654f..dd0af40fac1 100644 --- a/homeassistant/components/modem_callerid/strings.json +++ b/homeassistant/components/modem_callerid/strings.json @@ -20,5 +20,12 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "no_devices_found": "No remaining devices found" } + }, + "entity": { + "button": { + "phone_modem_reject": { + "name": "Phone modem reject" + } + } } } diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index d7f30ce5c3b..fafd7f9c8d2 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 5a61e306991..92b98abf374 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform, service -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/moon/manifest.json b/homeassistant/components/moon/manifest.json index 5da6a6b3359..6102b37fb13 100644 --- a/homeassistant/components/moon/manifest.json +++ b/homeassistant/components/moon/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/moon", "integration_type": "service", - "iot_class": "local_polling", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 10251fc679d..b7c5b8d7726 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -6,8 +6,7 @@ from astral import moon from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -47,7 +46,6 @@ class MoonSensorEntity(SensorEntity): """Representation of a Moon sensor.""" _attr_has_entity_name = True - _attr_name = "Phase" _attr_device_class = SensorDeviceClass.ENUM _attr_options = [ STATE_NEW_MOON, diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json index 1210fb6403e..d4d59f83674 100644 --- a/homeassistant/components/moon/strings.json +++ b/homeassistant/components/moon/strings.json @@ -13,6 +13,7 @@ "entity": { "sensor": { "phase": { + "name": "Phase", "state": { "first_quarter": "First quarter", "full_moon": "Full moon", diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 17918133614..c9578380048 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -21,7 +21,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_platform, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 360463d678f..bca1c1ef1dd 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 2876a4d49a1..59fc41df9b0 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -51,11 +51,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 457f9058242..8eab83b5d41 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -85,7 +85,6 @@ class MpdDevice(MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC - # pylint: disable=no-member def __init__(self, server, port, password, name): """Initialize the MPD device.""" self.server = server diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index cc0f37ea145..eb9ab56208e 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -3,6 +3,8 @@ ABBREVIATIONS = { "act_t": "action_topic", "act_tpl": "action_template", + "act_stat_t": "activity_state_topic", + "act_val_tpl": "activity_value_template", "atype": "automation_type", "aux_cmd_t": "aux_command_topic", "aux_stat_tpl": "aux_state_template", @@ -54,6 +56,8 @@ ABBREVIATIONS = { "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", @@ -111,6 +115,7 @@ ABBREVIATIONS = { "mode_stat_tpl": "mode_state_template", "modes": "modes", "name": "name", + "o": "origin", "obj_id": "object_id", "off_dly": "off_delay", "on_cmd_type": "on_command_type", @@ -120,6 +125,8 @@ ABBREVIATIONS = { "osc_cmd_tpl": "oscillation_command_template", "osc_stat_t": "oscillation_state_topic", "osc_val_tpl": "oscillation_value_template", + "pause_cmd_t": "pause_command_topic", + "pause_mw_cmd_tpl": "pause_command_template", "pct_cmd_t": "percentage_command_topic", "pct_cmd_tpl": "percentage_command_template", "pct_stat_t": "percentage_state_topic", @@ -214,6 +221,8 @@ ABBREVIATIONS = { "stat_tpl": "state_template", "stat_val_tpl": "state_value_template", "step": "step", + "strt_mw_cmd_t": "start_mowing_command_topic", + "strt_mw_cmd_tpl": "start_mowing_command_template", "stype": "subtype", "sug_dsp_prc": "suggested_display_precision", "sup_dur": "support_duration", @@ -275,3 +284,9 @@ DEVICE_ABBREVIATIONS = { "sw": "sw_version", "sa": "suggested_area", } + +ORIGIN_ABBREVIATIONS = { + "name": "name", + "sw": "sw_version", + "url": "support_url", +} diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 06f91403057..a0939fdc615 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -39,6 +39,7 @@ from .const import ( CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + CONF_SUPPORTED_FEATURES, ) from .debug_info import log_messages from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper @@ -47,6 +48,15 @@ from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) +_SUPPORTED_FEATURES = { + "arm_home": AlarmControlPanelEntityFeature.ARM_HOME, + "arm_away": AlarmControlPanelEntityFeature.ARM_AWAY, + "arm_night": AlarmControlPanelEntityFeature.ARM_NIGHT, + "arm_vacation": AlarmControlPanelEntityFeature.ARM_VACATION, + "arm_custom_bypass": AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + "trigger": AlarmControlPanelEntityFeature.TRIGGER, +} + CONF_CODE_ARM_REQUIRED = "code_arm_required" CONF_CODE_DISARM_REQUIRED = "code_disarm_required" CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" @@ -81,6 +91,9 @@ REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT" PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { + vol.Optional(CONF_SUPPORTED_FEATURES, default=list(_SUPPORTED_FEATURES)): [ + vol.In(_SUPPORTED_FEATURES) + ], vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_DISARM_REQUIRED, default=True): cv.boolean, @@ -167,6 +180,9 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): config[CONF_COMMAND_TEMPLATE], entity=self ).async_render + for feature in self._config[CONF_SUPPORTED_FEATURES]: + self._attr_supported_features |= _SUPPORTED_FEATURES[feature] + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -214,18 +230,6 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """Return the state of the device.""" return self._state - @property - def supported_features(self) -> AlarmControlPanelEntityFeature: - """Return the list of supported features.""" - return ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_VACATION - | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - | AlarmControlPanelEntityFeature.TRIGGER - ) - @property def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 07fbc0ca8c5..62f1f55401d 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -12,7 +12,6 @@ import time from typing import TYPE_CHECKING, Any import uuid -import async_timeout import attr import certifi @@ -362,7 +361,7 @@ class EnsureJobAfterCooldown: except asyncio.CancelledError: pass except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error cleaning up task", exc_info=True) + _LOGGER.exception("Error cleaning up task") class MQTT: @@ -918,7 +917,7 @@ class MQTT: # may be executed first. await self._register_mid(mid) try: - async with async_timeout.timeout(TIMEOUT_ACK): + async with asyncio.timeout(TIMEOUT_ACK): await self._pending_operations[mid].wait() except asyncio.TimeoutError: _LOGGER.warning( diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index f45d2852df0..d5bda57c2b3 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -45,6 +45,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, 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.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import TemperatureConverter @@ -77,6 +78,7 @@ from .const import ( CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, + DOMAIN, PAYLOAD_NONE, ) from .debug_info import log_messages @@ -92,8 +94,13 @@ from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) +MQTT_CLIMATE_AUX_DOCS = "https://www.home-assistant.io/integrations/climate.mqtt/" + DEFAULT_NAME = "MQTT HVAC" +# Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC +# and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 +# Support will be removed in HA Core 2024.3 CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" @@ -255,6 +262,9 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 vol.Optional(CONF_AUX_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template, vol.Optional(CONF_AUX_STATE_TOPIC): valid_subscribe_topic, @@ -327,7 +337,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ): cv.ensure_list, vol.Optional(CONF_SWING_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_TEMP_INITIAL): cv.positive_int, + vol.Optional(CONF_TEMP_INITIAL): vol.All(vol.Coerce(float)), vol.Optional(CONF_TEMP_MIN): vol.Coerce(float), vol.Optional(CONF_TEMP_MAX): vol.Coerce(float), vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float), @@ -353,6 +363,12 @@ PLATFORM_SCHEMA_MODERN = vol.All( # was removed in HA Core 2023.8 cv.removed(CONF_POWER_STATE_TEMPLATE), cv.removed(CONF_POWER_STATE_TOPIC), + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 + cv.deprecated(CONF_AUX_COMMAND_TOPIC), + cv.deprecated(CONF_AUX_STATE_TEMPLATE), + cv.deprecated(CONF_AUX_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, valid_preset_mode_configuration, valid_humidity_range_configuration, @@ -667,6 +683,9 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self._attr_swing_mode = SWING_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_hvac_mode = HVACMode.OFF + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 if self._topic[CONF_AUX_STATE_TOPIC] is None or self._optimistic: self._attr_is_aux_heat = False self._feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config @@ -738,12 +757,32 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if self._feature_preset_mode: support |= ClimateEntityFeature.PRESET_MODE + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 if (self._topic[CONF_AUX_STATE_TOPIC] is not None) or ( self._topic[CONF_AUX_COMMAND_TOPIC] is not None ): support |= ClimateEntityFeature.AUX_HEAT self._attr_supported_features = support + async def mqtt_async_added_to_hass(self) -> None: + """Handle deprecation issues.""" + if self._attr_supported_features & ClimateEntityFeature.AUX_HEAT: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_climate_aux_property_{self.entity_id}", + breaks_in_ha_version="2024.3.0", + is_fixable=False, + translation_key="deprecated_climate_aux_property", + translation_placeholders={ + "entity_id": self.entity_id, + }, + learn_more_url=MQTT_CLIMATE_AUX_DOCS, + severity=IssueSeverity.WARNING, + ) + def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} @@ -876,6 +915,9 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 @callback @log_messages(self.hass, self.entity_id) def handle_aux_mode_received(msg: ReceiveMessage) -> None: @@ -986,6 +1028,9 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): return + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 async def _set_aux_heat(self, state: bool) -> None: await self._publish( CONF_AUX_COMMAND_TOPIC, diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index bea8a900a83..9f960b0d909 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -563,9 +563,7 @@ async def async_get_broker_settings( ) schema = vol.Schema({cv.string: cv.template}) schema(validated_user_input[CONF_WS_HEADERS]) - except JSON_DECODE_EXCEPTIONS + ( # pylint: disable=wrong-exception-operation - vol.MultipleInvalid, - ): + except JSON_DECODE_EXCEPTIONS + (vol.MultipleInvalid,): errors["base"] = "bad_ws_headers" return False return True diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index cd4470ef22d..79e977a90cd 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -26,6 +26,7 @@ from . import ( fan as fan_platform, humidifier as humidifier_platform, image as image_platform, + lawn_mower as lawn_mower_platform, light as light_platform, lock as lock_platform, number as number_platform, @@ -99,6 +100,10 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [image_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.LAWN_MOWER.value: vol.All( + cv.ensure_list, + [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] + ), Platform.LOCK.value: vol.All( cv.ensure_list, [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index fcdfeb4bd7d..685e45700b5 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -17,6 +17,7 @@ CONF_COMMAND_TOPIC = "command_topic" CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" CONF_KEEPALIVE = "keepalive" +CONF_ORIGIN = "origin" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN CONF_SCHEMA = "schema" @@ -28,6 +29,7 @@ CONF_WS_PATH = "ws_path" CONF_WS_HEADERS = "ws_headers" CONF_WILL_MESSAGE = "will_message" CONF_PAYLOAD_RESET = "payload_reset" +CONF_SUPPORTED_FEATURES = "supported_features" CONF_ACTION_TEMPLATE = "action_template" CONF_ACTION_TOPIC = "action_topic" @@ -56,6 +58,19 @@ CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" +# Device and integration info options +CONF_IDENTIFIERS = "identifiers" +CONF_CONNECTIONS = "connections" +CONF_MANUFACTURER = "manufacturer" +CONF_HW_VERSION = "hw_version" +CONF_SW_VERSION = "sw_version" +CONF_VIA_DEVICE = "via_device" +CONF_DEPRECATED_VIA_HUB = "via_hub" +CONF_SUGGESTED_AREA = "suggested_area" +CONF_CONFIGURATION_URL = "configuration_url" +CONF_OBJECT_ID = "object_id" +CONF_SUPPORT_URL = "support_url" + DATA_MQTT = "mqtt" DATA_MQTT_AVAILABLE = "mqtt_client_available" @@ -119,6 +134,7 @@ PLATFORMS = [ Platform.FAN, Platform.HUMIDIFIER, Platform.IMAGE, + Platform.LAWN_MOWER, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, @@ -146,6 +162,7 @@ RELOADABLE_PLATFORMS = [ Platform.HUMIDIFIER, Platform.IMAGE, Platform.LIGHT, + Platform.LAWN_MOWER, Platform.LOCK, Platform.NUMBER, Platform.SCENE, diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index dd4eca9878a..67355d9bca5 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -165,6 +165,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): }, ) + @property + def force_update(self) -> bool: + """Do not force updates if the state is the same.""" + return False + async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 8e563a48cdd..37885b628d2 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -9,8 +9,10 @@ import re import time from typing import Any +import voluptuous as vol + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, CONF_PLATFORM +from homeassistant.const import CONF_DEVICE, CONF_NAME, CONF_PLATFORM from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv @@ -24,16 +26,19 @@ from homeassistant.loader import async_get_mqtt from homeassistant.util.json import json_loads_object from .. import mqtt -from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS +from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS, ORIGIN_ABBREVIATIONS from .const import ( ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, + CONF_ORIGIN, + CONF_SUPPORT_URL, + CONF_SW_VERSION, CONF_TOPIC, DOMAIN, ) -from .models import ReceiveMessage +from .models import MqttOriginInfo, ReceiveMessage from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -56,6 +61,7 @@ SUPPORTED_COMPONENTS = [ "fan", "humidifier", "image", + "lawn_mower", "light", "lock", "number", @@ -77,6 +83,16 @@ MQTT_DISCOVERY_DONE = "mqtt_discovery_done_{}" TOPIC_BASE = "~" +MQTT_ORIGIN_INFO_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_SUPPORT_URL): cv.configuration_url, + } + ), +) + class MQTTDiscoveryPayload(dict[str, Any]): """Class to hold and MQTT discovery payload and discovery data.""" @@ -94,6 +110,30 @@ def set_discovery_hash(hass: HomeAssistant, discovery_hash: tuple[str, str]) -> get_mqtt_data(hass).discovery_already_discovered.add(discovery_hash) +@callback +def async_log_discovery_origin_info( + message: str, discovery_payload: MQTTDiscoveryPayload +) -> None: + """Log information about the discovery and origin.""" + if CONF_ORIGIN not in discovery_payload: + _LOGGER.info(message) + return + origin_info: MqttOriginInfo = discovery_payload[CONF_ORIGIN] + sw_version_log = "" + if sw_version := origin_info.get("sw_version"): + sw_version_log = f", version: {sw_version}" + support_url_log = "" + if support_url := origin_info.get("support_url"): + support_url_log = f", support URL: {support_url}" + _LOGGER.info( + "%s from external application %s%s%s", + message, + origin_info["name"], + sw_version_log, + support_url_log, + ) + + async def async_start( # noqa: C901 hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry ) -> None: @@ -149,6 +189,22 @@ async def async_start( # noqa: C901 key = DEVICE_ABBREVIATIONS.get(key, key) device[key] = device.pop(abbreviated_key) + if CONF_ORIGIN in discovery_payload: + origin_info: dict[str, Any] = discovery_payload[CONF_ORIGIN] + try: + for key in list(origin_info): + abbreviated_key = key + key = ORIGIN_ABBREVIATIONS.get(key, key) + origin_info[key] = origin_info.pop(abbreviated_key) + MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) + except Exception: # pylint: disable=broad-except + _LOGGER.warning( + "Unable to parse origin information " + "from discovery message, got %s", + discovery_payload[CONF_ORIGIN], + ) + return + if CONF_AVAILABILITY in discovery_payload: for availability_conf in cv.ensure_list( discovery_payload[CONF_AVAILABILITY] @@ -246,17 +302,15 @@ async def async_start( # noqa: C901 if discovery_hash in mqtt_data.discovery_already_discovered: # Dispatch update - _LOGGER.info( - "Component has already been discovered: %s %s, sending update", - component, - discovery_id, - ) + message = f"Component has already been discovered: {component} {discovery_id}, sending update" + async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload ) elif payload: # Add component - _LOGGER.info("Found new component: %s %s", component, discovery_id) + message = f"Found new component: {component} {discovery_id}" + async_log_discovery_origin_info(message, payload) mqtt_data.discovery_already_discovered.add(discovery_hash) async_dispatcher_send( hass, MQTT_DISCOVERY_NEW.format(component, "mqtt"), payload diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 5a94ec754c0..6f8be33f21a 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -15,11 +15,7 @@ from homeassistant.components.event import ( EventEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_NAME, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,11 +32,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttValueTemplate, PayloadSentinel, diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index a21d45369f8..da62416d29e 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -12,10 +12,7 @@ import httpx import voluptuous as vol from homeassistant.components import image -from homeassistant.components.image import ( - DEFAULT_CONTENT_TYPE, - ImageEntity, -) +from homeassistant.components.image import DEFAULT_CONTENT_TYPE, ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py new file mode 100644 index 00000000000..44db3581f8b --- /dev/null +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -0,0 +1,254 @@ +"""Support for MQTT lawn mowers.""" +from __future__ import annotations + +from collections.abc import Callable +import contextlib +import functools +import logging + +import voluptuous as vol + +from homeassistant.components import lawn_mower +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, + DEFAULT_OPTIMISTIC, + DEFAULT_RETAIN, +) +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .models import ( + MqttCommandTemplate, + MqttValueTemplate, + PublishPayloadType, + ReceiveMessage, + ReceivePayloadType, +) +from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_ACTIVITY_STATE_TOPIC = "activity_state_topic" +CONF_ACTIVITY_VALUE_TEMPLATE = "activity_value_template" +CONF_DOCK_COMMAND_TOPIC = "dock_command_topic" +CONF_DOCK_COMMAND_TEMPLATE = "dock_command_template" +CONF_PAUSE_COMMAND_TOPIC = "pause_command_topic" +CONF_PAUSE_COMMAND_TEMPLATE = "pause_command_template" +CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" +CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" + +DEFAULT_NAME = "MQTT Lawn Mower" +ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}" + +MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() + +FEATURE_DOCK = "dock" +FEATURE_PAUSE = "pause" +FEATURE_START_MOWING = "start_mowing" + +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_ACTIVITY_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ACTIVITY_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_DOCK_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DOCK_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAUSE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_PAUSE_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_START_MOWING_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_START_MOWING_COMMAND_TOPIC): valid_publish_topic, + }, +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT lawn mower through YAML and through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, lawn_mower.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up the MQTT lawn mower.""" + async_add_entities([MqttLawnMower(hass, config, config_entry, discovery_data)]) + + +class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): + """Representation of an MQTT lawn mower.""" + + _default_name = DEFAULT_NAME + _entity_id_format = ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED + _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] + _command_topics: dict[str, str] + _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] + _optimistic: bool = False + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the MQTT lawn mower.""" + self._attr_current_option = None + LawnMowerEntity.__init__(self) + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._optimistic = config[CONF_OPTIMISTIC] + + self._value_template = MqttValueTemplate( + config.get(CONF_ACTIVITY_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + supported_features = LawnMowerEntityFeature(0) + self._command_topics = {} + if CONF_DOCK_COMMAND_TOPIC in config: + self._command_topics[FEATURE_DOCK] = config[CONF_DOCK_COMMAND_TOPIC] + supported_features |= LawnMowerEntityFeature.DOCK + if CONF_PAUSE_COMMAND_TOPIC in config: + self._command_topics[FEATURE_PAUSE] = config[CONF_PAUSE_COMMAND_TOPIC] + supported_features |= LawnMowerEntityFeature.PAUSE + if CONF_START_MOWING_COMMAND_TOPIC in config: + self._command_topics[FEATURE_START_MOWING] = config[ + CONF_START_MOWING_COMMAND_TOPIC + ] + supported_features |= LawnMowerEntityFeature.START_MOWING + self._attr_supported_features = supported_features + self._command_templates = {} + self._command_templates[FEATURE_DOCK] = MqttCommandTemplate( + config.get(CONF_DOCK_COMMAND_TEMPLATE), entity=self + ).async_render + self._command_templates[FEATURE_PAUSE] = MqttCommandTemplate( + config.get(CONF_PAUSE_COMMAND_TEMPLATE), entity=self + ).async_render + self._command_templates[FEATURE_START_MOWING] = MqttCommandTemplate( + config.get(CONF_START_MOWING_COMMAND_TEMPLATE), entity=self + ).async_render + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + payload = str(self._value_template(msg.payload)) + if not payload: + _LOGGER.debug( + "Invalid empty activity payload from topic %s, for entity %s", + msg.topic, + self.entity_id, + ) + return + if payload.lower() == "none": + self._attr_activity = None + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + return + + try: + self._attr_activity = LawnMowerActivity(payload) + except ValueError: + _LOGGER.error( + "Invalid activity for %s: '%s' (valid activies: %s)", + self.entity_id, + payload, + [option.value for option in LawnMowerActivity], + ) + return + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: + # Force into optimistic mode. + self._optimistic = True + else: + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + { + CONF_ACTIVITY_STATE_TOPIC: { + "topic": self._config.get(CONF_ACTIVITY_STATE_TOPIC), + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + }, + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + if self._optimistic and (last_state := await self.async_get_last_state()): + with contextlib.suppress(ValueError): + self._attr_activity = LawnMowerActivity(last_state.state) + + @property + def assumed_state(self) -> bool: + """Return true if we do optimistic updates.""" + return self._optimistic + + async def _async_operate(self, option: str, activity: LawnMowerActivity) -> None: + """Execute operation.""" + payload = self._command_templates[option](option) + if self._optimistic: + self._attr_activity = activity + self.async_write_ha_state() + + await self.async_publish( + self._command_topics[option], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def async_start_mowing(self) -> None: + """Start or resume mowing.""" + await self._async_operate("start_mowing", LawnMowerActivity.MOWING) + + async def async_dock(self) -> None: + """Dock the mower.""" + await self._async_operate("dock", LawnMowerActivity.DOCKED) + + async def async_pause(self) -> None: + """Pause the lawn mower.""" + await self._async_operate("pause", LawnMowerActivity.PAUSED) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 8f710eb5ea6..b7787912161 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -307,31 +307,31 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = ColorMode.HS self._attr_hs_color = (hue, saturation) elif color_mode == ColorMode.RGB: - r = int(values["color"]["r"]) # pylint: disable=invalid-name - g = int(values["color"]["g"]) # pylint: disable=invalid-name - b = int(values["color"]["b"]) # pylint: disable=invalid-name + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) self._attr_color_mode = ColorMode.RGB self._attr_rgb_color = (r, g, b) elif color_mode == ColorMode.RGBW: - r = int(values["color"]["r"]) # pylint: disable=invalid-name - g = int(values["color"]["g"]) # pylint: disable=invalid-name - b = int(values["color"]["b"]) # pylint: disable=invalid-name - w = int(values["color"]["w"]) # pylint: disable=invalid-name + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + w = int(values["color"]["w"]) self._attr_color_mode = ColorMode.RGBW self._attr_rgbw_color = (r, g, b, w) elif color_mode == ColorMode.RGBWW: - r = int(values["color"]["r"]) # pylint: disable=invalid-name - g = int(values["color"]["g"]) # pylint: disable=invalid-name - b = int(values["color"]["b"]) # pylint: disable=invalid-name - c = int(values["color"]["c"]) # pylint: disable=invalid-name - w = int(values["color"]["w"]) # pylint: disable=invalid-name + r = int(values["color"]["r"]) + g = int(values["color"]["g"]) + b = int(values["color"]["b"]) + c = int(values["color"]["c"]) + w = int(values["color"]["w"]) self._attr_color_mode = ColorMode.RGBWW self._attr_rgbww_color = (r, g, b, c, w) elif color_mode == ColorMode.WHITE: self._attr_color_mode = ColorMode.WHITE elif color_mode == ColorMode.XY: - x = float(values["color"]["x"]) # pylint: disable=invalid-name - y = float(values["color"]["y"]) # pylint: disable=invalid-name + x = float(values["color"]["x"]) + y = float(values["color"]["y"]) self._attr_color_mode = ColorMode.XY self._attr_xy_color = (x, y) except (KeyError, ValueError): diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 48b18a61782..3b28bc8804f 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -36,6 +36,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.device_registry import ( DeviceEntry, + DeviceInfo, EventDeviceRegistryUpdatedData, ) from homeassistant.helpers.dispatcher import ( @@ -44,7 +45,6 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity import ( ENTITY_CATEGORIES_SCHEMA, - DeviceInfo, Entity, async_generate_entity_id, ) @@ -69,9 +69,20 @@ from .const import ( ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, + CONF_CONFIGURATION_URL, + CONF_CONNECTIONS, + CONF_DEPRECATED_VIA_HUB, CONF_ENCODING, + CONF_HW_VERSION, + CONF_IDENTIFIERS, + CONF_MANUFACTURER, + CONF_OBJECT_ID, + CONF_ORIGIN, CONF_QOS, + CONF_SUGGESTED_AREA, + CONF_SW_VERSION, CONF_TOPIC, + CONF_VIA_DEVICE, DEFAULT_ENCODING, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, @@ -84,6 +95,7 @@ from .discovery import ( MQTT_DISCOVERY_DONE, MQTT_DISCOVERY_NEW, MQTT_DISCOVERY_UPDATED, + MQTT_ORIGIN_INFO_SCHEMA, MQTTDiscoveryPayload, clear_discovery_hash, set_discovery_hash, @@ -119,17 +131,6 @@ CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" -CONF_IDENTIFIERS = "identifiers" -CONF_CONNECTIONS = "connections" -CONF_MANUFACTURER = "manufacturer" -CONF_HW_VERSION = "hw_version" -CONF_SW_VERSION = "sw_version" -CONF_VIA_DEVICE = "via_device" -CONF_DEPRECATED_VIA_HUB = "via_hub" -CONF_SUGGESTED_AREA = "suggested_area" -CONF_CONFIGURATION_URL = "configuration_url" -CONF_OBJECT_ID = "object_id" - MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", "available", @@ -228,6 +229,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA, vol.Optional(CONF_ENABLED_BY_DEFAULT, default=True): cv.boolean, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, vol.Optional(CONF_ICON): cv.icon, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 9afa3de3f48..8c599469ff2 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -99,6 +99,16 @@ class PendingDiscovered(TypedDict): unsub: CALLBACK_TYPE +class MqttOriginInfo(TypedDict, total=False): + """Integration info of discovered entity.""" + + name: str + manufacturer: str + sw_version: str + hw_version: str + support_url: str + + class MqttCommandTemplate: """Class for rendering MQTT payload with command templates.""" @@ -231,11 +241,21 @@ class MqttValueTemplate: values, self._value_template, ) - rendered_payload = ( - self._value_template.async_render_with_possible_json_value( - payload, variables=values + try: + rendered_payload = ( + self._value_template.async_render_with_possible_json_value( + payload, variables=values + ) ) - ) + except Exception as ex: + _LOGGER.error( + "%s: %s rendering template for entity '%s', template: '%s'", + type(ex).__name__, + ex, + self._entity.entity_id if self._entity else "n/a", + self._value_template.template, + ) + raise ex return rendered_payload _LOGGER.debug( @@ -248,9 +268,24 @@ class MqttValueTemplate: default, self._value_template, ) - rendered_payload = self._value_template.async_render_with_possible_json_value( - payload, default, variables=values - ) + try: + rendered_payload = ( + self._value_template.async_render_with_possible_json_value( + payload, default, variables=values + ) + ) + except Exception as ex: + _LOGGER.error( + "%s: %s rendering template for entity '%s', template: " + "'%s', default value: %s and payload: %s", + type(ex).__name__, + ex, + self._entity.entity_id if self._entity else "n/a", + self._value_template.template, + default, + payload, + ) + raise ex return rendered_payload @@ -269,13 +304,12 @@ class EntityTopicState: try: entity.async_write_ha_state() except Exception: # pylint: disable=broad-except - _LOGGER.error( + _LOGGER.exception( "Exception raised when updating state of %s, topic: " "'%s' with payload: %s", entity.entity_id, msg.topic, msg.payload, - exc_info=True, ) @callback diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 87c56869d0c..fd876976fe6 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -17,11 +17,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .util import valid_publish_topic DEFAULT_NAME = "MQTT Scene" diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ae6033de5f9..d1b63b331ed 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -15,6 +15,10 @@ "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}" + }, + "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 deperated config options from your configration and restart HA to fix this issue." } }, "config": { diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 896ba21f802..02d9964bcd1 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -8,7 +8,6 @@ from pathlib import Path import tempfile from typing import Any -import async_timeout import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -71,7 +70,7 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: return state_reached_future.result() try: - async with async_timeout.timeout(AVAILABILITY_TIMEOUT): + async with asyncio.timeout(AVAILABILITY_TIMEOUT): # Await the client setup or an error state was received return await state_reached_future except asyncio.TimeoutError: diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py index b8551682f1f..cd692f00537 100644 --- a/homeassistant/components/mullvad/__init__.py +++ b/homeassistant/components/mullvad/__init__.py @@ -1,8 +1,8 @@ """The Mullvad VPN integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from mullvad_api import MullvadAPI from homeassistant.config_entries import ConfigEntry @@ -19,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mullvad VPN integration.""" async def async_get_mullvad_api_data(): - async with async_timeout.timeout(10): + async with asyncio.timeout(10): api = await hass.async_add_executor_job(MullvadAPI) return api.data diff --git a/homeassistant/components/mutesync/__init__.py b/homeassistant/components/mutesync/__init__.py index aa5e0d70fe9..cbbbbaa6a11 100644 --- a/homeassistant/components/mutesync/__init__.py +++ b/homeassistant/components/mutesync/__init__.py @@ -1,9 +1,9 @@ """The mütesync integration.""" from __future__ import annotations +import asyncio import logging -import async_timeout import mutesync from homeassistant.config_entries import ConfigEntry @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_data(): """Update the data.""" - async with async_timeout.timeout(2.5): + async with asyncio.timeout(2.5): state = await client.get_state() if state["muted"] is None or state["in_meeting"] is None: diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 3c9d92094f7..444643d5333 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -3,8 +3,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import update_coordinator -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index 7ebbc718a5b..e06c0b07c87 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any import aiohttp -import async_timeout import mutesync import voluptuous as vol @@ -27,7 +26,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, """ session = async_get_clientsession(hass) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): token = await mutesync.authenticate(session, data["host"]) except aiohttp.ClientResponseError as error: if error.status == 403: diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py index 172a01017c4..a9dd82caef1 100644 --- a/homeassistant/components/mycroft/notify.py +++ b/homeassistant/components/mycroft/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from mycroftapi import MycroftAPI # pylint: disable=import-error +from mycroftapi import MycroftAPI from homeassistant.components.notify import BaseNotificationService from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index d5b4730c2de..c50ea579a14 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 42c5a40636e..a89de3abf69 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -11,8 +11,9 @@ from mysensors.sensor import ChildSensor from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import ( CHILD_CALLBACK, diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 1d016a791e3..ce602e6266d 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -9,7 +9,6 @@ import socket import sys from typing import Any -import async_timeout from mysensors import BaseAsyncGateway, Message, Sensor, mysensors import voluptuous as vol @@ -107,7 +106,7 @@ async def try_connect( connect_task = None try: connect_task = asyncio.create_task(gateway.start()) - async with async_timeout.timeout(GATEWAY_READY_TIMEOUT): + async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() return True except asyncio.TimeoutError: @@ -299,7 +298,7 @@ async def _gw_start( # Gatways connected via mqtt doesn't send gateway ready message. return try: - async with async_timeout.timeout(GATEWAY_READY_TIMEOUT): + async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() except asyncio.TimeoutError: _LOGGER.warning( diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index d32a64dc1e6..6a6e7efa1b3 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 54c1dc9ad5a..262ee54101b 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 73276017254..d5881f52d8d 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -6,7 +6,6 @@ import logging from typing import cast from aiohttp.client_exceptions import ClientConnectorError, ClientError -import async_timeout from nettigo_air_monitor import ( ApiError, AuthFailedError, @@ -23,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -111,7 +110,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): async def _async_update_data(self) -> NAMSensors: """Update data via library.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await self.nam.async_update() # We do not need to catch AuthFailed exception here because sensor data is # always available without authorization. diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index eef4c33e5f0..7eee84a66a4 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -8,7 +8,6 @@ import logging from typing import Any from aiohttp.client_exceptions import ClientConnectorError -import async_timeout from nettigo_air_monitor import ( ApiError, AuthFailedError, @@ -51,7 +50,7 @@ async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: options = ConnectionOptions(host) nam = await NettigoAirMonitor.create(websession, options) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): mac = await nam.async_get_mac_address() return NamConfig(mac, nam.auth_enabled) @@ -67,7 +66,7 @@ async def async_check_credentials( nam = await NettigoAirMonitor.create(websession, options) - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await nam.async_check_credentials() diff --git a/homeassistant/components/nanoleaf/entity.py b/homeassistant/components/nanoleaf/entity.py index 16fb746049d..73d635a46a1 100644 --- a/homeassistant/components/nanoleaf/entity.py +++ b/homeassistant/components/nanoleaf/entity.py @@ -2,7 +2,7 @@ from aionanoleaf import Nanoleaf -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index e7b402aed36..52bc841f3b5 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -12,9 +12,10 @@ from homeassistant.components.application_credentials import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, 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 . import api @@ -39,7 +40,13 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.CAMERA, Platform.VACUUM, Platform.SWITCH, Platform.SENSOR] +PLATFORMS = [ + Platform.CAMERA, + Platform.VACUUM, + Platform.SWITCH, + Platform.SENSOR, + Platform.BUTTON, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -65,6 +72,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "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 diff --git a/homeassistant/components/neato/button.py b/homeassistant/components/neato/button.py new file mode 100644 index 00000000000..8b23bbe4681 --- /dev/null +++ b/homeassistant/components/neato/button.py @@ -0,0 +1,41 @@ +"""Support for Neato buttons.""" +from __future__ import annotations + +from pybotvac import Robot + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import NEATO_ROBOTS +from .entity import NeatoEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Neato button from config entry.""" + entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]] + + async_add_entities(entities, True) + + +class NeatoDismissAlertButton(NeatoEntity, ButtonEntity): + """Representation of a dismiss_alert button entity.""" + + _attr_translation_key = "dismiss_alert" + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + robot: Robot, + ) -> None: + """Initialize a dismiss_alert Neato button entity.""" + super().__init__(robot) + self._attr_unique_id = f"{robot.serial}_dismiss_alert" + + async def async_press(self) -> None: + """Press the button.""" + await self.hass.async_add_executor_job(self.robot.dismiss_current_alert) diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 6429056afa1..c1513bb1de6 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -12,16 +12,10 @@ from urllib3.response import HTTPResponse from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - NEATO_DOMAIN, - NEATO_LOGIN, - NEATO_MAP_DATA, - NEATO_ROBOTS, - SCAN_INTERVAL_MINUTES, -) +from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -48,19 +42,20 @@ async def async_setup_entry( async_add_entities(dev, True) -class NeatoCleaningMap(Camera): +class NeatoCleaningMap(NeatoEntity, Camera): """Neato cleaning map for last clean.""" + _attr_translation_key = "cleaning_map" + def __init__( self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None ) -> None: """Initialize Neato cleaning map.""" - super().__init__() - self.robot = robot + super().__init__(robot) + Camera.__init__(self) self.neato = neato self._mapdata = mapdata self._available = neato is not None - self._robot_name = f"{self.robot.name} Cleaning Map" self._robot_serial: str = self.robot.serial self._generated_at: str | None = None self._image_url: str | None = None @@ -114,11 +109,6 @@ class NeatoCleaningMap(Camera): self._generated_at = map_data.get("generated_at") self._available = True - @property - def name(self) -> str: - """Return the name of this camera.""" - return self._robot_name - @property def unique_id(self) -> str: """Return unique ID.""" @@ -129,11 +119,6 @@ class NeatoCleaningMap(Camera): """Return if the robot is available.""" return self._available - @property - def device_info(self) -> DeviceInfo: - """Device info for neato robot.""" - return DeviceInfo(identifiers={(NEATO_DOMAIN, self._robot_serial)}) - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" diff --git a/homeassistant/components/neato/entity.py b/homeassistant/components/neato/entity.py new file mode 100644 index 00000000000..43072f19693 --- /dev/null +++ b/homeassistant/components/neato/entity.py @@ -0,0 +1,27 @@ +"""Base entity for Neato.""" +from __future__ import annotations + +from pybotvac import Robot + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import NEATO_DOMAIN + + +class NeatoEntity(Entity): + """Base Neato entity.""" + + _attr_has_entity_name = True + + def __init__(self, robot: Robot) -> None: + """Initialize Neato entity.""" + self.robot = robot + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + identifiers={(NEATO_DOMAIN, self.robot.serial)}, + name=self.robot.name, + ) diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 654a57ab2bb..5222ec938c8 100644 --- a/homeassistant/components/neato/manifest.json +++ b/homeassistant/components/neato/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/neato", "iot_class": "cloud_polling", "loggers": ["pybotvac"], - "requirements": ["pybotvac==0.0.23"] + "requirements": ["pybotvac==0.0.24"] } diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 3831c68ac6c..452f1bc3a9c 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -12,10 +12,10 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -41,14 +41,13 @@ async def async_setup_entry( async_add_entities(dev, True) -class NeatoSensor(SensorEntity): +class NeatoSensor(NeatoEntity, SensorEntity): """Neato sensor.""" def __init__(self, neato: NeatoHub, robot: Robot) -> None: """Initialize Neato sensor.""" - self.robot = robot + super().__init__(robot) self._available: bool = False - self._robot_name: str = f"{self.robot.name} {BATTERY}" self._robot_serial: str = self.robot.serial self._state: dict[str, Any] | None = None @@ -68,11 +67,6 @@ class NeatoSensor(SensorEntity): self._available = True _LOGGER.debug("self._state=%s", self._state) - @property - def name(self) -> str: - """Return the name of this sensor.""" - return self._robot_name - @property def unique_id(self) -> str: """Return unique ID.""" @@ -104,8 +98,3 @@ class NeatoSensor(SensorEntity): def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return PERCENTAGE - - @property - def device_info(self) -> DeviceInfo: - """Device info for neato robot.""" - return DeviceInfo(identifiers={(NEATO_DOMAIN, self._robot_serial)}) diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 6136ac94e99..d611abb83b0 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -19,6 +19,23 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "entity": { + "button": { + "dismiss_alert": { + "name": "Dismiss alert" + } + }, + "camera": { + "cleaning_map": { + "name": "Cleaning map" + } + }, + "switch": { + "schedule": { + "name": "Schedule" + } + } + }, "services": { "custom_cleaning": { "name": "Zone cleaning service", diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index 0619e616b98..a80d05eef23 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -12,10 +12,10 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -45,15 +45,16 @@ async def async_setup_entry( async_add_entities(dev, True) -class NeatoConnectedSwitch(SwitchEntity): +class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): """Neato Connected Switches.""" + _attr_translation_key = "schedule" + def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None: """Initialize the Neato Connected switches.""" + super().__init__(robot) self.type = switch_type - self.robot = robot self._available = False - self._robot_name = f"{self.robot.name} {SWITCH_TYPES[self.type][0]}" self._state: dict[str, Any] | None = None self._schedule_state: str | None = None self._clean_state = None @@ -85,11 +86,6 @@ class NeatoConnectedSwitch(SwitchEntity): "Schedule state for '%s': %s", self.entity_id, self._schedule_state ) - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._robot_name - @property def available(self) -> bool: """Return True if entity is available.""" @@ -112,11 +108,6 @@ class NeatoConnectedSwitch(SwitchEntity): """Device entity category.""" return EntityCategory.CONFIG - @property - def device_info(self) -> DeviceInfo: - """Device info for neato robot.""" - return DeviceInfo(identifiers={(NEATO_DOMAIN, self._robot_serial)}) - def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 4d402fbb8bb..ecc39e515c2 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -30,13 +30,13 @@ from .const import ( ALERTS, ERRORS, MODE, - NEATO_DOMAIN, NEATO_LOGIN, NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES, ) +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ async def async_setup_entry( ) -class NeatoConnectedVacuum(StateVacuumEntity): +class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Representation of a Neato Connected Vacuum.""" _attr_icon = "mdi:robot-vacuum-variant" @@ -106,6 +106,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): | VacuumEntityFeature.MAP | VacuumEntityFeature.LOCATE ) + _attr_name = None def __init__( self, @@ -115,10 +116,9 @@ class NeatoConnectedVacuum(StateVacuumEntity): persistent_maps: dict[str, Any] | None, ) -> None: """Initialize the Neato Connected Vacuum.""" - self.robot = robot + super().__init__(robot) self._attr_available: bool = neato is not None self._mapdata = mapdata - self._attr_name: str = self.robot.name self._robot_has_map: bool = self.robot.has_persistent_maps self._robot_maps = persistent_maps self._robot_serial: str = self.robot.serial @@ -299,14 +299,12 @@ class NeatoConnectedVacuum(StateVacuumEntity): @property def device_info(self) -> DeviceInfo: """Device info for neato robot.""" - stats = self._robot_stats - return DeviceInfo( - identifiers={(NEATO_DOMAIN, self._robot_serial)}, - manufacturer=stats["battery"]["vendor"] if stats else None, - model=stats["model"] if stats else None, - name=self._attr_name, - sw_version=stats["firmware"] if stats else None, - ) + device_info = super().device_info + if self._robot_stats: + device_info["manufacturer"] = self._robot_stats["battery"]["vendor"] + device_info["model"] = self._robot_stats["model"] + device_info["sw_version"] = self._robot_stats["firmware"] + return device_info def start(self) -> None: """Start cleaning or resume cleaning.""" diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py deleted file mode 100644 index 6d9331744ef..00000000000 --- a/homeassistant/components/nest/binary_sensor.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Support for Nest binary sensors that dispatches between API versions.""" - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DATA_SDM -from .legacy.binary_sensor import async_setup_legacy_entry - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the binary sensors.""" - assert DATA_SDM not in entry.data - await async_setup_legacy_entry(hass, entry, async_add_entities) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 3f8c99d7658..90c4056161e 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -7,6 +7,7 @@ import datetime import functools import logging from pathlib import Path +from typing import cast from google_nest_sdm.camera_traits import ( CameraImageTrait, @@ -23,7 +24,7 @@ from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -71,9 +72,24 @@ class NestCamera(Camera): self._stream: RtspStream | None = None self._create_stream_url_lock = asyncio.Lock() self._stream_refresh_unsub: Callable[[], None] | None = None - self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits + self._attr_is_streaming = False + self._attr_supported_features = CameraEntityFeature(0) + self._rtsp_live_stream_trait: CameraLiveStreamTrait | None = None + if CameraLiveStreamTrait.NAME in self._device.traits: + self._attr_is_streaming = True + self._attr_supported_features |= CameraEntityFeature.STREAM + trait = cast( + CameraLiveStreamTrait, self._device.traits[CameraLiveStreamTrait.NAME] + ) + if StreamingProtocol.RTSP in trait.supported_protocols: + self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return self._rtsp_live_stream_trait is not None + @property def unique_id(self) -> str: """Return a unique ID.""" @@ -95,14 +111,6 @@ class NestCamera(Camera): """Return the camera model.""" return self._device_info.device_model - @property - def supported_features(self) -> CameraEntityFeature: - """Flag supported features.""" - supported_features = CameraEntityFeature(0) - if CameraLiveStreamTrait.NAME in self._device.traits: - supported_features |= CameraEntityFeature.STREAM - return supported_features - @property def frontend_stream_type(self) -> StreamType | None: """Return the type of stream supported by this camera.""" @@ -125,18 +133,15 @@ class NestCamera(Camera): async def stream_source(self) -> str | None: """Return the source of the stream.""" - if not self.supported_features & CameraEntityFeature.STREAM: - return None - if CameraLiveStreamTrait.NAME not in self._device.traits: - return None - trait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.RTSP not in trait.supported_protocols: + if not self._rtsp_live_stream_trait: return None async with self._create_stream_url_lock: if not self._stream: _LOGGER.debug("Fetching stream url") try: - self._stream = await trait.generate_rtsp_stream() + self._stream = ( + await self._rtsp_live_stream_trait.generate_rtsp_stream() + ) except ApiException as err: raise HomeAssistantError(f"Nest API error: {err}") from err self._schedule_stream_refresh() @@ -204,10 +209,7 @@ class NestCamera(Camera): ) -> bytes | None: """Return bytes of camera image.""" # Use the thumbnail from RTSP stream, or a placeholder if stream is - # not supported (e.g. WebRTC) - stream = await self.async_create_stream() - if stream: - return await stream.async_get_image(width, height) + # not supported (e.g. WebRTC) as a fallback when 'use_stream_for_stills' if False return await self.hass.async_add_executor_job(self.placeholder_image) @classmethod diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 307bd201b4d..03fb79eb78e 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -32,7 +32,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_DEVICE_MANAGER, DOMAIN @@ -75,6 +74,7 @@ FAN_INV_MODES = list(FAN_INV_MODE_MAP) MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API MIN_TEMP = 10 MAX_TEMP = 32 +MIN_TEMP_RANGE = 1.66667 async def async_setup_entry( @@ -105,17 +105,18 @@ class ThermostatEntity(ClimateEntity): """Initialize ThermostatEntity.""" self._device = device self._device_info = NestDeviceInfo(device) - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" # The API "name" field is a unique device identifier. - return self._device.name - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return self._device_info.device_info + self._attr_unique_id = device.name + self._attr_device_info = self._device_info.device_info + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + if mode_trait := device.traits.get(ThermostatModeTrait.NAME): + self._attr_hvac_modes = [ + THERMOSTAT_MODE_MAP[mode] + for mode in mode_trait.available_modes + if mode in THERMOSTAT_MODE_MAP + ] + else: + self._attr_hvac_modes = [] @property def available(self) -> bool: @@ -129,11 +130,6 @@ class ThermostatEntity(ClimateEntity): self._device.add_update_listener(self.async_write_ha_state) ) - @property - def temperature_unit(self) -> str: - """Return the unit of temperature measurement for the system.""" - return UnitOfTemperature.CELSIUS - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -200,24 +196,6 @@ class ThermostatEntity(ClimateEntity): hvac_mode = THERMOSTAT_MODE_MAP[trait.mode] return hvac_mode - @property - def hvac_modes(self) -> list[HVACMode]: - """List of available operation modes.""" - supported_modes = [] - for mode in self._get_device_hvac_modes: - if mode in THERMOSTAT_MODE_MAP: - supported_modes.append(THERMOSTAT_MODE_MAP[mode]) - return supported_modes - - @property - def _get_device_hvac_modes(self) -> set[str]: - """Return the set of SDM API hvac modes supported by the device.""" - modes = [] - if ThermostatModeTrait.NAME in self._device.traits: - trait = self._device.traits[ThermostatModeTrait.NAME] - modes.extend(trait.available_modes) - return set(modes) - @property def hvac_action(self) -> HVACAction | None: """Return the current HVAC action (heating, cooling).""" @@ -313,6 +291,13 @@ class ThermostatEntity(ClimateEntity): try: if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: if low_temp and high_temp: + if high_temp - low_temp < MIN_TEMP_RANGE: + # Ensure there is a minimum gap from the new temp. Pick + # the temp that is not changing as the one to move. + if abs(high_temp - self.target_temperature_high) < 0.01: + high_temp = low_temp + MIN_TEMP_RANGE + else: + low_temp = high_temp - MIN_TEMP_RANGE await trait.set_range(low_temp, high_temp) elif hvac_mode == HVACMode.COOL and temp: await trait.set_cool(temp) diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 891365655de..1bdb60ee1b4 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -9,7 +9,7 @@ from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import CONNECTIVITY_TRAIT_OFFLINE, DATA_DEVICE_MANAGER, DOMAIN diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index e26a32965a3..f575f227753 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -29,7 +29,13 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + CoreState, + Event, + HomeAssistant, + ServiceCall, +) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, @@ -38,6 +44,7 @@ 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.typing import ConfigType from . import api @@ -108,6 +115,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "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 @@ -128,8 +149,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await session.async_ensure_token_valid() except aiohttp.ClientResponseError as ex: - _LOGGER.debug("API error: %s (%s)", ex.code, ex.message) - if ex.code in ( + _LOGGER.debug("API error: %s (%s)", ex.status, ex.message) + if ex.status in ( HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN, diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 94844578d9d..9f34df9b39c 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -27,8 +27,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index ff6783ecaa3..4cf5766b6b5 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -10,7 +10,8 @@ from pyatmo.modules.device_types import ( from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME from .data_handler import PUBLIC, NetatmoDataHandler diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 98958fbbb9b..2dc86833003 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -19,7 +19,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py index 1b398610ac2..02c3fef1162 100644 --- a/homeassistant/components/nexia/binary_sensor.py +++ b/homeassistant/components/nexia/binary_sensor.py @@ -23,7 +23,7 @@ async def async_setup_entry( thermostat = nexia_home.get_thermostat_by_id(thermostat_id) entities.append( NexiaBinarySensor( - coordinator, thermostat, "is_blower_active", "Blower Active" + coordinator, thermostat, "is_blower_active", "blower_active" ) ) if thermostat.has_emergency_heat(): @@ -32,7 +32,7 @@ async def async_setup_entry( coordinator, thermostat, "is_emergency_heat_active", - "Emergency Heat Active", + "emergency_heat_active", ) ) @@ -42,16 +42,16 @@ async def async_setup_entry( class NexiaBinarySensor(NexiaThermostatEntity, BinarySensorEntity): """Provices Nexia BinarySensor support.""" - def __init__(self, coordinator, thermostat, sensor_call, sensor_name): + def __init__(self, coordinator, thermostat, sensor_call, translation_key): """Initialize the nexia sensor.""" super().__init__( coordinator, thermostat, - name=f"{thermostat.get_name()} {sensor_name}", unique_id=f"{thermostat.thermostat_id}_{sensor_call}", ) self._call = sensor_call self._state = None + self._attr_translation_key = translation_key @property def is_on(self): diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index fe31263a86c..e331108f6ba 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -150,13 +150,13 @@ async def async_setup_entry( class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): """Provides Nexia Climate support.""" + _attr_name = None + def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone ) -> None: """Initialize the thermostat.""" - super().__init__( - coordinator, zone, name=zone.get_name(), unique_id=zone.zone_id - ) + super().__init__(coordinator, zone, zone.zone_id) unit = self._thermostat.get_unit() min_humidity, max_humidity = self._thermostat.get_humidity_setpoint_limits() min_setpoint, max_setpoint = self._thermostat.get_setpoint_limits() diff --git a/homeassistant/components/nexia/const.py b/homeassistant/components/nexia/const.py index 493fdd8a403..fe2d6527ea0 100644 --- a/homeassistant/components/nexia/const.py +++ b/homeassistant/components/nexia/const.py @@ -2,10 +2,11 @@ from homeassistant.const import Platform PLATFORMS = [ - Platform.SENSOR, Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SCENE, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/nexia/coordinator.py b/homeassistant/components/nexia/coordinator.py index b83ebcf9c40..cd515e44b14 100644 --- a/homeassistant/components/nexia/coordinator.py +++ b/homeassistant/components/nexia/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from nexia.home import NexiaHome @@ -14,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_UPDATE_RATE = 120 -class NexiaDataUpdateCoordinator(DataUpdateCoordinator[None]): +class NexiaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """DataUpdateCoordinator for nexia homes.""" def __init__( @@ -29,8 +30,9 @@ class NexiaDataUpdateCoordinator(DataUpdateCoordinator[None]): _LOGGER, name="Nexia update", update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE), + always_update=False, ) - async def _async_update_data(self) -> None: + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from API endpoint.""" return await self.nexia_home.update() diff --git a/homeassistant/components/nexia/entity.py b/homeassistant/components/nexia/entity.py index 6b017db4d34..dfb2366d34a 100644 --- a/homeassistant/components/nexia/entity.py +++ b/homeassistant/components/nexia/entity.py @@ -9,11 +9,11 @@ from homeassistant.const import ( ATTR_SUGGESTED_AREA, ATTR_VIA_DEVICE, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -31,21 +31,20 @@ class NexiaEntity(CoordinatorEntity[NexiaDataUpdateCoordinator]): _attr_attribution = ATTRIBUTION - def __init__( - self, coordinator: NexiaDataUpdateCoordinator, name: str, unique_id: str - ) -> None: + def __init__(self, coordinator: NexiaDataUpdateCoordinator, unique_id: str) -> None: """Initialize the entity.""" super().__init__(coordinator) self._attr_unique_id = unique_id - self._attr_name = name class NexiaThermostatEntity(NexiaEntity): """Base class for nexia devices attached to a thermostat.""" - def __init__(self, coordinator, thermostat, name, unique_id): + _attr_has_entity_name = True + + def __init__(self, coordinator, thermostat, unique_id): """Initialize the entity.""" - super().__init__(coordinator, name, unique_id) + super().__init__(coordinator, unique_id) self._thermostat: NexiaThermostat = thermostat self._attr_device_info = DeviceInfo( configuration_url=self.coordinator.nexia_home.root_url, @@ -89,9 +88,9 @@ class NexiaThermostatEntity(NexiaEntity): class NexiaThermostatZoneEntity(NexiaThermostatEntity): """Base class for nexia devices attached to a thermostat.""" - def __init__(self, coordinator, zone, name, unique_id): + def __init__(self, coordinator, zone, unique_id): """Initialize the entity.""" - super().__init__(coordinator, zone.thermostat, name, unique_id) + super().__init__(coordinator, zone.thermostat, unique_id) self._zone: NexiaThermostatZone = zone zone_name = self._zone.get_name() self._attr_device_info |= { diff --git a/homeassistant/components/nexia/number.py b/homeassistant/components/nexia/number.py new file mode 100644 index 00000000000..b44c6a4c48f --- /dev/null +++ b/homeassistant/components/nexia/number.py @@ -0,0 +1,72 @@ +"""Support for Nexia / Trane XL Thermostats.""" +from __future__ import annotations + +from nexia.home import NexiaHome +from nexia.thermostat import NexiaThermostat + +from homeassistant.components.number import NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import NexiaDataUpdateCoordinator +from .entity import NexiaThermostatEntity +from .util import percent_conv + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for a Nexia device.""" + coordinator: NexiaDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + nexia_home: NexiaHome = coordinator.nexia_home + + entities: list[NexiaThermostatEntity] = [] + for thermostat_id in nexia_home.get_thermostat_ids(): + thermostat = nexia_home.get_thermostat_by_id(thermostat_id) + if thermostat.has_variable_fan_speed(): + entities.append( + NexiaFanSpeedEntity( + coordinator, thermostat, thermostat.get_variable_fan_speed_limits() + ) + ) + async_add_entities(entities) + + +class NexiaFanSpeedEntity(NexiaThermostatEntity, NumberEntity): + """Provides Nexia Fan Speed support.""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_icon = "mdi:fan" + _attr_translation_key = "fan_speed" + + def __init__( + self, + coordinator: NexiaDataUpdateCoordinator, + thermostat: NexiaThermostat, + valid_range: tuple[float, float], + ) -> None: + """Initialize the entity.""" + super().__init__( + coordinator, + thermostat, + unique_id=f"{thermostat.thermostat_id}_fan_speed_setpoint", + ) + min_value, max_value = valid_range + self._attr_native_min_value = percent_conv(min_value) + self._attr_native_max_value = percent_conv(max_value) + + @property + def native_value(self) -> float: + """Return the current value.""" + fan_speed = self._thermostat.get_fan_speed_setpoint() + return percent_conv(fan_speed) + + async def async_set_native_value(self, value: float) -> None: + """Set a new value.""" + await self._thermostat.set_fan_setpoint(value / 100) + self._signal_thermostat_update() diff --git a/homeassistant/components/nexia/scene.py b/homeassistant/components/nexia/scene.py index 941785f8221..3a21c61badd 100644 --- a/homeassistant/components/nexia/scene.py +++ b/homeassistant/components/nexia/scene.py @@ -43,9 +43,9 @@ class NexiaAutomationScene(NexiaEntity, Scene): """Initialize the automation scene.""" super().__init__( coordinator, - name=automation.name, - unique_id=automation.automation_id, + automation.automation_id, ) + self._attr_name = automation.name self._automation: NexiaAutomation = automation self._attr_extra_state_attributes = {ATTR_DESCRIPTION: automation.description} diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index a67ac681199..79e07bc71b4 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( coordinator, thermostat, "get_system_status", - "System Status", + "system_status", None, None, None, @@ -52,7 +52,7 @@ async def async_setup_entry( coordinator, thermostat, "get_air_cleaner_mode", - "Air Cleaner Mode", + "air_cleaner_mode", None, None, None, @@ -65,7 +65,7 @@ async def async_setup_entry( coordinator, thermostat, "get_current_compressor_speed", - "Current Compressor Speed", + "current_compressor_speed", None, PERCENTAGE, SensorStateClass.MEASUREMENT, @@ -77,7 +77,7 @@ async def async_setup_entry( coordinator, thermostat, "get_requested_compressor_speed", - "Requested Compressor Speed", + "requested_compressor_speed", None, PERCENTAGE, SensorStateClass.MEASUREMENT, @@ -95,7 +95,7 @@ async def async_setup_entry( coordinator, thermostat, "get_outdoor_temperature", - "Outdoor Temperature", + "outdoor_temperature", SensorDeviceClass.TEMPERATURE, unit, SensorStateClass.MEASUREMENT, @@ -108,7 +108,7 @@ async def async_setup_entry( coordinator, thermostat, "get_relative_humidity", - "Relative Humidity", + None, SensorDeviceClass.HUMIDITY, PERCENTAGE, SensorStateClass.MEASUREMENT, @@ -129,7 +129,7 @@ async def async_setup_entry( coordinator, zone, "get_temperature", - "Temperature", + None, SensorDeviceClass.TEMPERATURE, unit, SensorStateClass.MEASUREMENT, @@ -139,7 +139,7 @@ async def async_setup_entry( # Zone Status entities.append( NexiaThermostatZoneSensor( - coordinator, zone, "get_status", "Zone Status", None, None, None + coordinator, zone, "get_status", "zone_status", None, None, None ) ) # Setpoint Status @@ -148,7 +148,7 @@ async def async_setup_entry( coordinator, zone, "get_setpoint_status", - "Zone Setpoint Status", + "zone_setpoint_status", None, None, None, @@ -166,7 +166,7 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): coordinator, thermostat, sensor_call, - sensor_name, + translation_key, sensor_class, sensor_unit, state_class, @@ -176,7 +176,6 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): super().__init__( coordinator, thermostat, - name=f"{thermostat.get_name()} {sensor_name}", unique_id=f"{thermostat.thermostat_id}_{sensor_call}", ) self._call = sensor_call @@ -184,6 +183,8 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): self._attr_device_class = sensor_class self._attr_native_unit_of_measurement = sensor_unit self._attr_state_class = state_class + if translation_key is not None: + self._attr_translation_key = translation_key @property def native_value(self): @@ -204,7 +205,7 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): coordinator, zone, sensor_call, - sensor_name, + translation_key, sensor_class, sensor_unit, state_class, @@ -215,7 +216,6 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): super().__init__( coordinator, zone, - name=f"{zone.get_name()} {sensor_name}", unique_id=f"{zone.zone_id}_{sensor_call}", ) self._call = sensor_call @@ -223,6 +223,8 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): self._attr_device_class = sensor_class self._attr_native_unit_of_measurement = sensor_unit self._attr_state_class = state_class + if translation_key is not None: + self._attr_translation_key = translation_key @property def native_value(self): diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index f3d343ffda3..9e49f4bb793 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -18,6 +18,49 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "binary_sensor": { + "blower_active": { + "name": "Blower active" + }, + "emergency_heat_active": { + "name": "Emergency heat active" + } + }, + "number": { + "fan_speed": { + "name": "Fan speed" + } + }, + "sensor": { + "system_status": { + "name": "System status" + }, + "air_cleaner_mode": { + "name": "Air cleaner mode" + }, + "current_compressor_speed": { + "name": "Current compressor speed" + }, + "requested_compressor_speed": { + "name": "Requested compressor speed" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "zone_status": { + "name": "Zone status" + }, + "zone_setpoint_status": { + "name": "Zone setpoint status" + } + }, + "switch": { + "hold": { + "name": "Hold" + } + } + }, "services": { "set_aircleaner_mode": { "name": "Set air cleaner mode", diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 643a4d585c4..7f191d39c73 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -39,13 +39,14 @@ async def async_setup_entry( class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): """Provides Nexia hold switch support.""" + _attr_translation_key = "hold" + def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone ) -> None: """Initialize the hold mode switch.""" - switch_name = f"{zone.get_name()} Hold" zone_id = zone.zone_id - super().__init__(coordinator, zone, name=switch_name, unique_id=zone_id) + super().__init__(coordinator, zone, zone_id) @property def is_on(self) -> bool: diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 8e2f39cf9b5..9cfe4aa7f70 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,5 +1,7 @@ """The Nextcloud integration.""" +import logging + from nextcloudmonitor import ( NextcloudMonitor, NextcloudMonitorAuthorizationError, @@ -17,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -26,10 +28,25 @@ PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nextcloud integration.""" + # migrate old entity unique ids + entity_reg = er.async_get(hass) + entities: list[er.RegistryEntry] = er.async_entries_for_config_entry( + entity_reg, entry.entry_id + ) + for entity in entities: + old_uid_start = f"{entry.data[CONF_URL]}#nextcloud_" + new_uid_start = f"{entry.entry_id}#" + if entity.unique_id.startswith(old_uid_start): + new_uid = entity.unique_id.replace(old_uid_start, new_uid_start) + _LOGGER.debug("migrate unique id '%s' to '%s'", entity.unique_id, new_uid) + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_uid) + def _connect_nc(): return NextcloudMonitor( entry.data[CONF_URL], @@ -50,10 +67,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ncm, entry, ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 3cf3cc3ae2a..313d555a3d7 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -1,8 +1,14 @@ """Summary binary data from Nextcoud.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from typing import Final + +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 @@ -10,12 +16,40 @@ from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity -BINARY_SENSORS = ( - "nextcloud_system_enable_avatars", - "nextcloud_system_enable_previews", - "nextcloud_system_filelocking.enabled", - "nextcloud_system_debug", -) +BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [ + BinarySensorEntityDescription( + key="jit_enabled", + translation_key="nextcloud_jit_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + BinarySensorEntityDescription( + key="jit_on", + translation_key="nextcloud_jit_on", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + BinarySensorEntityDescription( + key="system_debug", + translation_key="nextcloud_system_debug", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="system_enable_avatars", + translation_key="nextcloud_system_enable_avatars", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="system_enable_previews", + translation_key="nextcloud_system_enable_previews", + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="system_filelocking.enabled", + translation_key="nextcloud_system_filelocking_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] async def async_setup_entry( @@ -25,9 +59,9 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudBinarySensor(coordinator, name, entry) - for name in coordinator.data - if name in BINARY_SENSORS + NextcloudBinarySensor(coordinator, entry, sensor) + for sensor in BINARY_SENSORS + if sensor.key in coordinator.data ] ) @@ -38,4 +72,5 @@ class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.coordinator.data.get(self.item) == "yes" + val = self.coordinator.data.get(self.entity_description.key) + return val is True or val == "yes" diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index 73a07a77e23..b5dc5e29507 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -54,8 +54,13 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): key_path += f"{key}_" leaf = True result.update(self._get_data_points(value, key_path, leaf)) + elif key == "cpuload" and isinstance(value, list): + result[f"{key_path}{key}_1"] = value[0] + result[f"{key_path}{key}_5"] = value[1] + result[f"{key_path}{key}_15"] = value[2] + leaf = False else: - result[f"{DOMAIN}_{key_path}{key}"] = value + result[f"{key_path}{key}"] = value leaf = False return result diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 4308e573859..b9dab9179c1 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,8 +1,10 @@ """Base entity for the Nextcloud integration.""" +from urllib.parse import urlparse + from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import slugify from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -12,19 +14,20 @@ class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" _attr_has_entity_name = True - _attr_icon = "mdi:cloud" def __init__( - self, coordinator: NextcloudDataUpdateCoordinator, item: str, entry: ConfigEntry + self, + coordinator: NextcloudDataUpdateCoordinator, + entry: ConfigEntry, + description: EntityDescription, ) -> None: """Initialize the Nextcloud sensor.""" super().__init__(coordinator) - self.item = item - self._attr_translation_key = slugify(item) - self._attr_unique_id = f"{coordinator.url}#{item}" + self._attr_unique_id = f"{entry.entry_id}#{description.key}" self._attr_device_info = DeviceInfo( - name="Nextcloud", - identifiers={(DOMAIN, entry.entry_id)}, - sw_version=coordinator.data.get("nextcloud_system_version"), configuration_url=coordinator.url, + identifiers={(DOMAIN, entry.entry_id)}, + name=urlparse(coordinator.url).netloc, + sw_version=coordinator.data.get("system_version"), ) + self.entity_description = description diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index a5df872e084..0133a9e7f76 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -1,61 +1,587 @@ """Summary data from Nextcoud.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfInformation, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utc_from_timestamp from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity -SENSORS = ( - "nextcloud_system_version", - "nextcloud_system_theme", - "nextcloud_system_memcache.local", - "nextcloud_system_memcache.distributed", - "nextcloud_system_memcache.locking", - "nextcloud_system_freespace", - "nextcloud_system_cpuload", - "nextcloud_system_mem_total", - "nextcloud_system_mem_free", - "nextcloud_system_swap_total", - "nextcloud_system_swap_free", - "nextcloud_system_apps_num_installed", - "nextcloud_system_apps_num_updates_available", - "nextcloud_system_apps_app_updates_calendar", - "nextcloud_system_apps_app_updates_contacts", - "nextcloud_system_apps_app_updates_tasks", - "nextcloud_system_apps_app_updates_twofactor_totp", - "nextcloud_storage_num_users", - "nextcloud_storage_num_files", - "nextcloud_storage_num_storages", - "nextcloud_storage_num_storages_local", - "nextcloud_storage_num_storages_home", - "nextcloud_storage_num_storages_other", - "nextcloud_shares_num_shares", - "nextcloud_shares_num_shares_user", - "nextcloud_shares_num_shares_groups", - "nextcloud_shares_num_shares_link", - "nextcloud_shares_num_shares_mail", - "nextcloud_shares_num_shares_room", - "nextcloud_shares_num_shares_link_no_password", - "nextcloud_shares_num_fed_shares_sent", - "nextcloud_shares_num_fed_shares_received", - "nextcloud_shares_permissions_3_1", - "nextcloud_server_webserver", - "nextcloud_server_php_version", - "nextcloud_server_php_memory_limit", - "nextcloud_server_php_max_execution_time", - "nextcloud_server_php_upload_max_filesize", - "nextcloud_database_type", - "nextcloud_database_version", - "nextcloud_activeUsers_last5minutes", - "nextcloud_activeUsers_last1hour", - "nextcloud_activeUsers_last24hours", -) +UNIT_OF_LOAD: Final[str] = "load" + + +@dataclass +class NextcloudSensorEntityDescription(SensorEntityDescription): + """Describes Nextcloud sensor entity.""" + + value_fn: Callable[ + [str | int | float], str | int | float | datetime + ] = lambda value: value + + +SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ + NextcloudSensorEntityDescription( + key="activeUsers_last1hour", + translation_key="nextcloud_activeusers_last1hour", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:account-multiple", + ), + NextcloudSensorEntityDescription( + key="activeUsers_last24hours", + translation_key="nextcloud_activeusers_last24hours", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:account-multiple", + ), + NextcloudSensorEntityDescription( + key="activeUsers_last5minutes", + translation_key="nextcloud_activeusers_last5minutes", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:account-multiple", + ), + NextcloudSensorEntityDescription( + key="cache_expunges", + translation_key="nextcloud_cache_expunges", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="cache_mem_size", + translation_key="nextcloud_cache_mem_size", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="cache_memory_type", + translation_key="nextcloud_cache_memory_type", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="cache_num_entries", + translation_key="nextcloud_cache_num_entries", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="cache_num_hits", + translation_key="nextcloud_cache_num_hits", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="cache_num_inserts", + translation_key="nextcloud_cache_num_inserts", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="cache_num_misses", + translation_key="nextcloud_cache_num_misses", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="cache_num_slots", + translation_key="nextcloud_cache_num_slots", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="cache_start_time", + translation_key="nextcloud_cache_start_time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda val: utc_from_timestamp(float(val)), + ), + NextcloudSensorEntityDescription( + key="cache_ttl", + translation_key="nextcloud_cache_ttl", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="database_size", + translation_key="nextcloud_database_size", + device_class=SensorDeviceClass.DATA_SIZE, + icon="mdi:database", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="database_type", + translation_key="nextcloud_database_type", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:database", + ), + NextcloudSensorEntityDescription( + key="database_version", + translation_key="nextcloud_database_version", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:database", + ), + NextcloudSensorEntityDescription( + key="interned_strings_usage_buffer_size", + translation_key="nextcloud_interned_strings_usage_buffer_size", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="interned_strings_usage_free_memory", + translation_key="nextcloud_interned_strings_usage_free_memory", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="interned_strings_usage_number_of_strings", + translation_key="nextcloud_interned_strings_usage_number_of_strings", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="interned_strings_usage_used_memory", + translation_key="nextcloud_interned_strings_usage_used_memory", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="jit_buffer_free", + translation_key="nextcloud_jit_buffer_free", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="jit_buffer_size", + translation_key="nextcloud_jit_buffer_size", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="jit_kind", + translation_key="nextcloud_jit_kind", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="jit_opt_flags", + translation_key="nextcloud_jit_opt_flags", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="jit_opt_level", + translation_key="nextcloud_jit_opt_level", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_blacklist_miss_ratio", + translation_key="nextcloud_opcache_statistics_blacklist_miss_ratio", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=PERCENTAGE, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_blacklist_misses", + translation_key="nextcloud_opcache_statistics_blacklist_misses", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_hash_restarts", + translation_key="nextcloud_opcache_statistics_hash_restarts", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_hits", + translation_key="nextcloud_opcache_statistics_hits", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_last_restart_time", + translation_key="nextcloud_opcache_statistics_last_restart_time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda val: utc_from_timestamp(float(val)), + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_manual_restarts", + translation_key="nextcloud_opcache_statistics_manual_restarts", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_max_cached_keys", + translation_key="nextcloud_opcache_statistics_max_cached_keys", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_misses", + translation_key="nextcloud_opcache_statistics_misses", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_num_cached_keys", + translation_key="nextcloud_opcache_statistics_num_cached_keys", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_num_cached_scripts", + translation_key="nextcloud_opcache_statistics_num_cached_scripts", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_oom_restarts", + translation_key="nextcloud_opcache_statistics_oom_restarts", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_opcache_hit_rate", + translation_key="nextcloud_opcache_statistics_opcache_hit_rate", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, + ), + NextcloudSensorEntityDescription( + key="opcache_statistics_start_time", + translation_key="nextcloud_opcache_statistics_start_time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda val: utc_from_timestamp(float(val)), + ), + NextcloudSensorEntityDescription( + key="server_php_opcache_memory_usage_current_wasted_percentage", + translation_key="nextcloud_server_php_opcache_memory_usage_current_wasted_percentage", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:language-php", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, + ), + NextcloudSensorEntityDescription( + key="server_php_opcache_memory_usage_free_memory", + translation_key="nextcloud_server_php_opcache_memory_usage_free_memory", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:language-php", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="server_php_opcache_memory_usage_used_memory", + translation_key="nextcloud_server_php_opcache_memory_usage_used_memory", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:language-php", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="server_php_opcache_memory_usage_wasted_memory", + translation_key="nextcloud_server_php_opcache_memory_usage_wasted_memory", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:language-php", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="server_php_max_execution_time", + translation_key="nextcloud_server_php_max_execution_time", + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + icon="mdi:language-php", + native_unit_of_measurement=UnitOfTime.SECONDS, + ), + NextcloudSensorEntityDescription( + key="server_php_memory_limit", + translation_key="nextcloud_server_php_memory_limit", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.CONFIG, + icon="mdi:language-php", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="server_php_upload_max_filesize", + translation_key="nextcloud_server_php_upload_max_filesize", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.CONFIG, + icon="mdi:language-php", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="server_php_version", + translation_key="nextcloud_server_php_version", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:language-php", + ), + NextcloudSensorEntityDescription( + key="server_webserver", + translation_key="nextcloud_server_webserver", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_fed_shares_sent", + translation_key="nextcloud_shares_num_fed_shares_sent", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_fed_shares_received", + translation_key="nextcloud_shares_num_fed_shares_received", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_shares", + translation_key="nextcloud_shares_num_shares", + ), + NextcloudSensorEntityDescription( + key="shares_num_shares_groups", + translation_key="nextcloud_shares_num_shares_groups", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_shares_link", + translation_key="nextcloud_shares_num_shares_link", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_shares_link_no_password", + translation_key="nextcloud_shares_num_shares_link_no_password", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_shares_mail", + translation_key="nextcloud_shares_num_shares_mail", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_shares_room", + translation_key="nextcloud_shares_num_shares_room", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="shares_num_shares_user", + translation_key="nextcloud_shares_num_shares_user", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="sma_avail_mem", + translation_key="nextcloud_sma_avail_mem", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="sma_num_seg", + translation_key="nextcloud_sma_num_seg", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="sma_seg_size", + translation_key="nextcloud_sma_seg_size", + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.MEGABYTES, + ), + NextcloudSensorEntityDescription( + key="storage_num_files", + translation_key="nextcloud_storage_num_files", + ), + NextcloudSensorEntityDescription( + key="storage_num_storages", + translation_key="nextcloud_storage_num_storages", + ), + NextcloudSensorEntityDescription( + key="storage_num_storages_home", + translation_key="nextcloud_storage_num_storages_home", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="storage_num_storages_local", + translation_key="nextcloud_storage_num_storages_local", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="storage_num_storages_other", + translation_key="nextcloud_storage_num_storages_other", + entity_category=EntityCategory.DIAGNOSTIC, + ), + NextcloudSensorEntityDescription( + key="storage_num_users", + translation_key="nextcloud_storage_num_users", + ), + NextcloudSensorEntityDescription( + key="system_apps_num_installed", + translation_key="nextcloud_system_apps_num_installed", + ), + NextcloudSensorEntityDescription( + key="system_apps_num_updates_available", + translation_key="nextcloud_system_apps_num_updates_available", + icon="mdi:update", + ), + NextcloudSensorEntityDescription( + key="system_cpuload_1", + translation_key="nextcloud_system_cpuload_1", + native_unit_of_measurement=UNIT_OF_LOAD, + icon="mdi:chip", + suggested_display_precision=2, + ), + NextcloudSensorEntityDescription( + key="system_cpuload_5", + translation_key="nextcloud_system_cpuload_5", + native_unit_of_measurement=UNIT_OF_LOAD, + icon="mdi:chip", + suggested_display_precision=2, + ), + NextcloudSensorEntityDescription( + key="system_cpuload_15", + translation_key="nextcloud_system_cpuload_15", + native_unit_of_measurement=UNIT_OF_LOAD, + icon="mdi:chip", + suggested_display_precision=2, + ), + NextcloudSensorEntityDescription( + key="system_freespace", + translation_key="nextcloud_system_freespace", + device_class=SensorDeviceClass.DATA_SIZE, + icon="mdi:harddisk", + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + ), + NextcloudSensorEntityDescription( + key="system_mem_free", + translation_key="nextcloud_system_mem_free", + device_class=SensorDeviceClass.DATA_SIZE, + icon="mdi:memory", + native_unit_of_measurement=UnitOfInformation.KILOBYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + ), + NextcloudSensorEntityDescription( + key="system_mem_total", + translation_key="nextcloud_system_mem_total", + device_class=SensorDeviceClass.DATA_SIZE, + icon="mdi:memory", + native_unit_of_measurement=UnitOfInformation.KILOBYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + ), + NextcloudSensorEntityDescription( + key="system_memcache.distributed", + translation_key="nextcloud_system_memcache_distributed", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="system_memcache.local", + translation_key="nextcloud_system_memcache_local", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="system_memcache.locking", + translation_key="nextcloud_system_memcache_locking", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + NextcloudSensorEntityDescription( + key="system_swap_total", + translation_key="nextcloud_system_swap_total", + device_class=SensorDeviceClass.DATA_SIZE, + icon="mdi:memory", + native_unit_of_measurement=UnitOfInformation.KILOBYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + ), + NextcloudSensorEntityDescription( + key="system_swap_free", + translation_key="nextcloud_system_swap_free", + device_class=SensorDeviceClass.DATA_SIZE, + icon="mdi:memory", + native_unit_of_measurement=UnitOfInformation.KILOBYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + ), + NextcloudSensorEntityDescription( + key="system_theme", + translation_key="nextcloud_system_theme", + ), + NextcloudSensorEntityDescription( + key="system_version", + translation_key="nextcloud_system_version", + ), +] async def async_setup_entry( @@ -65,9 +591,9 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudSensor(coordinator, name, entry) - for name in coordinator.data - if name in SENSORS + NextcloudSensor(coordinator, entry, sensor) + for sensor in SENSORS + if sensor.key in coordinator.data ] ) @@ -75,7 +601,10 @@ async def async_setup_entry( class NextcloudSensor(NextcloudEntity, SensorEntity): """Represents a Nextcloud sensor.""" + entity_description: NextcloudSensorEntityDescription + @property - def native_value(self) -> StateType: + def native_value(self) -> str | int | float | datetime: """Return the state for this sensor.""" - return self.coordinator.data.get(self.item) + val = self.coordinator.data.get(self.entity_description.key) + return self.entity_description.value_fn(val) # type: ignore[arg-type] diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 6c70421bf93..cfe57f201ca 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -31,6 +31,15 @@ }, "entity": { "binary_sensor": { + "nextcloud_jit_enabled": { + "name": "JIT enabled" + }, + "nextcloud_jit_on": { + "name": "JIT active" + }, + "nextcloud_system_debug": { + "name": "Debug enabled" + }, "nextcloud_system_enable_avatars": { "name": "Avatars enabled" }, @@ -39,50 +48,206 @@ }, "nextcloud_system_filelocking_enabled": { "name": "Filelocking enabled" - }, - "nextcloud_system_debug": { - "name": "Debug enabled" } }, "sensor": { - "nextcloud_system_version": { - "name": "System version" + "nextcloud_activeusers_last1hour": { + "name": "Amount of active users last hour" }, - "nextcloud_system_theme": { - "name": "System theme" + "nextcloud_activeusers_last24hours": { + "name": "Amount of active users last day" }, - "nextcloud_system_memcache_local": { - "name": "System memcache local" + "nextcloud_activeusers_last5minutes": { + "name": "Amount of active users last 5 minutes" }, - "nextcloud_system_memcache_distributed": { - "name": "System memcache distributed" + "nextcloud_cache_expunges": { + "name": "Cache expunges" }, - "nextcloud_system_memcache_locking": { - "name": "System memcache locking" + "nextcloud_cache_mem_size": { + "name": "Cache memory size" }, - "nextcloud_system_freespace": { - "name": "Free space" + "nextcloud_cache_memory_type": { + "name": "Cache memory" }, - "nextcloud_system_cpuload": { - "name": "CPU Load" + "nextcloud_cache_num_entries": { + "name": "Cache number of entires" }, - "nextcloud_system_mem_total": { - "name": "Total memory" + "nextcloud_cache_num_hits": { + "name": "Cache number of hits" }, - "nextcloud_system_mem_free": { - "name": "Free memory" + "nextcloud_cache_num_inserts": { + "name": "Cache number of inserts" }, - "nextcloud_system_swap_total": { - "name": "Total swap memory" + "nextcloud_cache_num_misses": { + "name": "Cache number of misses" }, - "nextcloud_system_swap_free": { - "name": "Free swap memory" + "nextcloud_cache_num_slots": { + "name": "Cache number of slots" }, - "nextcloud_system_apps_num_installed": { - "name": "Apps installed" + "nextcloud_cache_start_time": { + "name": "Cache start time" }, - "nextcloud_system_apps_num_updates_available": { - "name": "Updates available" + "nextcloud_cache_ttl": { + "name": "Cache ttl" + }, + "nextcloud_database_size": { + "name": "Databse size" + }, + "nextcloud_database_type": { + "name": "Database type" + }, + "nextcloud_database_version": { + "name": "Database version" + }, + "nextcloud_interned_strings_usage_buffer_size": { + "name": "Interned buffer size" + }, + "nextcloud_interned_strings_usage_free_memory": { + "name": "Interned free memory" + }, + "nextcloud_interned_strings_usage_number_of_strings": { + "name": "Interned number of strings" + }, + "nextcloud_interned_strings_usage_used_memory": { + "name": "Interned used memory" + }, + "nextcloud_jit_buffer_free": { + "name": "JIT buffer free" + }, + "nextcloud_jit_buffer_size": { + "name": "JIT buffer size" + }, + "nextcloud_jit_kind": { + "name": "JIT kind" + }, + "nextcloud_jit_opt_flags": { + "name": "JIT opt flags" + }, + "nextcloud_jit_opt_level": { + "name": "JIT opt level" + }, + "nextcloud_opcache_statistics_blacklist_miss_ratio": { + "name": "Opcache blacklist miss ratio" + }, + "nextcloud_opcache_statistics_blacklist_misses": { + "name": "Opcache blacklist misses" + }, + "nextcloud_opcache_statistics_hash_restarts": { + "name": "Opcache hash restarts" + }, + "nextcloud_opcache_statistics_hits": { + "name": "Opcache hits" + }, + "nextcloud_opcache_statistics_last_restart_time": { + "name": "Opcache last restart time" + }, + "nextcloud_opcache_statistics_manual_restarts": { + "name": "Opcache manual restarts" + }, + "nextcloud_opcache_statistics_max_cached_keys": { + "name": "Opcache max cached keys" + }, + "nextcloud_opcache_statistics_misses": { + "name": "Opcache misses" + }, + "nextcloud_opcache_statistics_num_cached_keys": { + "name": "Opcache cached keys" + }, + "nextcloud_opcache_statistics_num_cached_scripts": { + "name": "Opcache cached scripts" + }, + "nextcloud_opcache_statistics_oom_restarts": { + "name": "Opcache out of memory restarts" + }, + "nextcloud_opcache_statistics_opcache_hit_rate": { + "name": "Opcache hit rate" + }, + "nextcloud_opcache_statistics_start_time": { + "name": "Opcache start time" + }, + "nextcloud_server_php_max_execution_time": { + "name": "PHP max execution time" + }, + "nextcloud_server_php_memory_limit": { + "name": "PHP memory limit" + }, + "nextcloud_server_php_opcache_memory_usage_current_wasted_percentage": { + "name": "Opcache current wasted percentage" + }, + "nextcloud_server_php_opcache_memory_usage_free_memory": { + "name": "Opcache free memory" + }, + "nextcloud_server_php_opcache_memory_usage_used_memory": { + "name": "Opcache used memory" + }, + "nextcloud_server_php_opcache_memory_usage_wasted_memory": { + "name": "Opcache wasted memory" + }, + "nextcloud_server_php_upload_max_filesize": { + "name": "PHP upload maximum filesize" + }, + "nextcloud_server_php_version": { + "name": "PHP version" + }, + "nextcloud_server_webserver": { + "name": "Webserver" + }, + "nextcloud_shares_num_fed_shares_received": { + "name": "Amount of shares received" + }, + "nextcloud_shares_num_fed_shares_sent": { + "name": "Amount of shares sent" + }, + "nextcloud_shares_num_shares": { + "name": "Amount of shares" + }, + "nextcloud_shares_num_shares_groups": { + "name": "Amount of group shares" + }, + "nextcloud_shares_num_shares_link": { + "name": "Amount of link shares" + }, + "nextcloud_shares_num_shares_link_no_password": { + "name": "Amount of passwordless link shares" + }, + "nextcloud_shares_num_shares_mail": { + "name": "Amount of mail shares" + }, + "nextcloud_shares_num_shares_room": { + "name": "Amount of room shares" + }, + "nextcloud_shares_num_shares_user": { + "name": "Amount of user shares" + }, + "nextcloud_shares_permissions_3_1": { + "name": "Permissions 3.1" + }, + "nextcloud_sma_avail_mem": { + "name": "SMA available memory" + }, + "nextcloud_sma_num_seg": { + "name": "SMA number of segments" + }, + "nextcloud_sma_seg_size": { + "name": "SMA segment size" + }, + "nextcloud_storage_num_files": { + "name": "Amount of files" + }, + "nextcloud_storage_num_storages": { + "name": "Amount of storages" + }, + "nextcloud_storage_num_storages_home": { + "name": "Amount of storages at home" + }, + "nextcloud_storage_num_storages_local": { + "name": "Amount of local storages" + }, + "nextcloud_storage_num_storages_other": { + "name": "Amount of other storages" + }, + "nextcloud_storage_num_users": { + "name": "Amount of user" }, "nextcloud_system_apps_app_updates_calendar": { "name": "Calendar updates" @@ -96,83 +261,50 @@ "nextcloud_system_apps_app_updates_twofactor_totp": { "name": "Two factor authentication updates" }, - "nextcloud_storage_num_users": { - "name": "Amount of user" + "nextcloud_system_apps_num_installed": { + "name": "Apps installed" }, - "nextcloud_storage_num_files": { - "name": "Amount of files" + "nextcloud_system_apps_num_updates_available": { + "name": "Updates available" }, - "nextcloud_storage_num_storages": { - "name": "Amount of storages" + "nextcloud_system_cpuload_1": { + "name": "CPU Load last 1 minute" }, - "nextcloud_storage_num_storages_local": { - "name": "Amount of local storages" + "nextcloud_system_cpuload_15": { + "name": "CPU Load last 15 minutes" }, - "nextcloud_storage_num_storages_home": { - "name": "Amount of storages at home" + "nextcloud_system_cpuload_5": { + "name": "CPU Load last 5 minutes" }, - "nextcloud_storage_num_storages_other": { - "name": "Amount of other storages" + "nextcloud_system_freespace": { + "name": "Free space" }, - "nextcloud_shares_num_shares": { - "name": "Amount of shares" + "nextcloud_system_mem_free": { + "name": "Free memory" }, - "nextcloud_shares_num_shares_user": { - "name": "Amount of user shares" + "nextcloud_system_mem_total": { + "name": "Total memory" }, - "nextcloud_shares_num_shares_groups": { - "name": "Amount of group shares" + "nextcloud_system_memcache_distributed": { + "name": "System memcache distributed" }, - "nextcloud_shares_num_shares_link": { - "name": "Amount of link shares" + "nextcloud_system_memcache_local": { + "name": "System memcache local" }, - "nextcloud_shares_num_shares_mail": { - "name": "Amount of mail shares" + "nextcloud_system_memcache_locking": { + "name": "System memcache locking" }, - "nextcloud_shares_num_shares_room": { - "name": "Amount of room shares" + "nextcloud_system_swap_free": { + "name": "Free swap memory" }, - "nextcloud_shares_num_shares_link_no_password": { - "name": "Amount of passwordless link shares" + "nextcloud_system_swap_total": { + "name": "Total swap memory" }, - "nextcloud_shares_num_fed_shares_sent": { - "name": "Amount of shares sent" + "nextcloud_system_theme": { + "name": "System theme" }, - "nextcloud_shares_num_fed_shares_received": { - "name": "Amount of shares received" - }, - "nextcloud_shares_permissions_3_1": { - "name": "Permissions 3.1" - }, - "nextcloud_server_webserver": { - "name": "Webserver" - }, - "nextcloud_server_php_version": { - "name": "PHP version" - }, - "nextcloud_server_php_memory_limit": { - "name": "PHP memory limit" - }, - "nextcloud_server_php_max_execution_time": { - "name": "PHP max execution time" - }, - "nextcloud_server_php_upload_max_filesize": { - "name": "PHP upload maximum filesize" - }, - "nextcloud_database_type": { - "name": "Database type" - }, - "nextcloud_database_version": { - "name": "Database version" - }, - "nextcloud_activeusers_last5minutes": { - "name": "Amount of active users last 5 minutes" - }, - "nextcloud_activeusers_last1hour": { - "name": "Amount of active users last hour" - }, - "nextcloud_activeusers_last24hours": { - "name": "Amount of active users last day" + "nextcloud_system_version": { + "name": "System version" } } } diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 9e67ccfa4fc..011b487910f 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -7,7 +7,6 @@ import logging from typing import TypeVar from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from nextdns import ( AnalyticsDnssec, AnalyticsEncryption, @@ -27,8 +26,7 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -76,7 +74,7 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): async def _async_update_data(self) -> CoordinatorDataT: """Update data via internal method.""" try: - async with timeout(10): + async with asyncio.timeout(10): return await self._async_update_data_internal() except (ApiError, ClientConnectorError, InvalidApiKeyError) as err: raise UpdateFailed(err) from err @@ -145,7 +143,7 @@ class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStat _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] -COORDINATORS = [ +COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator], timedelta]] = [ (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator, UPDATE_INTERVAL_CONNECTION), (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), @@ -163,29 +161,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass) try: - async with timeout(10): + async with asyncio.timeout(10): nextdns = await NextDns.create(websession, api_key) except (ApiError, ClientConnectorError, asyncio.TimeoutError) as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {} - tasks = [] + coordinators = {} # Independent DataUpdateCoordinator is used for each API endpoint to avoid # unnecessary requests when entities using this endpoint are disabled. for coordinator_name, coordinator_class, update_interval in COORDINATORS: - hass.data[DOMAIN][entry.entry_id][coordinator_name] = coordinator_class( - hass, nextdns, profile_id, update_interval - ) - tasks.append( - hass.data[DOMAIN][entry.entry_id][ - coordinator_name - ].async_config_entry_first_refresh() - ) + coordinator = coordinator_class(hass, nextdns, profile_id, update_interval) + tasks.append(coordinator.async_config_entry_first_refresh()) + coordinators[coordinator_name] = coordinator await asyncio.gather(*tasks) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 5c9bf04cfc1..3985644a478 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any from aiohttp.client_exceptions import ClientConnectorError -from async_timeout import timeout from nextdns import ApiError, InvalidApiKeyError, NextDns import voluptuous as vol @@ -38,7 +37,7 @@ class NextDnsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: self.api_key = user_input[CONF_API_KEY] try: - async with timeout(10): + async with asyncio.timeout(10): self.nextdns = await NextDns.create( websession, user_input[CONF_API_KEY] ) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index a38e2182ad7..01a51f015d9 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -25,7 +25,8 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, async_generate_entity_id +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 0df787de986..4ab709ae947 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -24,6 +24,7 @@ from homeassistant.components.climate import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -70,7 +71,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_TEMPERATURE ) - _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.HEAT] + _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.HEAT_COOL] _attr_target_temperature_step = 0.5 _attr_max_temp = 35.0 _attr_min_temp = 5.0 @@ -101,7 +102,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): self._attr_unique_id = f"{coordinator.unique_id}-{key}" self._attr_device_info = coordinator.device_info self._attr_hvac_action = HVACAction.IDLE - self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_mode = HVACMode.AUTO self._attr_target_temperature_high = None self._attr_target_temperature_low = None self._attr_target_temperature = None @@ -138,7 +139,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): self._attr_current_temperature = _get_float(self._coil_current) - mode = HVACMode.OFF + mode = HVACMode.AUTO if _get_value(self._coil_use_room_sensor) == "ON": if ( _get_value(self._coil_cooling_with_room_sensor) @@ -225,3 +226,25 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): if (temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None: await coordinator.async_write_coil(self._coil_setpoint_cool, temperature) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + coordinator = self.coordinator + + if hvac_mode == HVACMode.HEAT_COOL: + await coordinator.async_write_coil( + self._coil_cooling_with_room_sensor, "ON" + ) + await coordinator.async_write_coil(self._coil_use_room_sensor, "ON") + elif hvac_mode == HVACMode.HEAT: + await coordinator.async_write_coil( + self._coil_cooling_with_room_sensor, "OFF" + ) + await coordinator.async_write_coil(self._coil_use_room_sensor, "ON") + elif hvac_mode == HVACMode.AUTO: + await coordinator.async_write_coil( + self._coil_cooling_with_room_sensor, "OFF" + ) + await coordinator.async_write_coil(self._coil_use_room_sensor, "OFF") + else: + raise HomeAssistantError(f"{hvac_mode} mode not supported for {self.name}") diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index 8aabad2c9fc..d9e89a2d56c 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfFrequency, UnitOfPower, UnitOfTemperature, UnitOfTime, @@ -110,6 +111,13 @@ UNIT_DESCRIPTIONS = { state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.HOURS, ), + "Hz": SensorEntityDescription( + key="Hz", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + ), } diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index fbb8e32bebe..4ac2518ffb6 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -1,11 +1,11 @@ """The Nina integration.""" from __future__ import annotations +import asyncio from dataclasses import dataclass import re from typing import Any -from async_timeout import timeout from pynina import ApiError, Nina from homeassistant.config_entries import ConfigEntry @@ -16,6 +16,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( _LOGGER, + ALL_MATCH_REGEX, + CONF_AREA_FILTER, CONF_FILTER_CORONA, CONF_HEADLINE_FILTER, CONF_REGIONS, @@ -42,8 +44,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data.pop(CONF_FILTER_CORONA, None) hass.config_entries.async_update_entry(entry, data=new_data) + if CONF_AREA_FILTER not in entry.data: + new_data = {**entry.data, CONF_AREA_FILTER: ALL_MATCH_REGEX} + hass.config_entries.async_update_entry(entry, data=new_data) + coordinator = NINADataUpdateCoordinator( - hass, regions, entry.data[CONF_HEADLINE_FILTER] + hass, + regions, + entry.data[CONF_HEADLINE_FILTER], + entry.data[CONF_AREA_FILTER], ) await coordinator.async_config_entry_first_refresh() @@ -77,6 +86,7 @@ class NinaWarningData: sender: str severity: str recommended_actions: str + affected_areas: str sent: str start: str expires: str @@ -89,12 +99,17 @@ class NINADataUpdateCoordinator( """Class to manage fetching NINA data API.""" def __init__( - self, hass: HomeAssistant, regions: dict[str, str], headline_filter: str + self, + hass: HomeAssistant, + regions: dict[str, str], + headline_filter: str, + area_filter: str, ) -> None: """Initialize.""" self._regions: dict[str, str] = regions self._nina: Nina = Nina(async_get_clientsession(hass)) self.headline_filter: str = headline_filter + self.area_filter: str = area_filter for region in regions: self._nina.addRegion(region) @@ -103,7 +118,7 @@ class NINADataUpdateCoordinator( async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: """Update data.""" - async with timeout(10): + async with asyncio.timeout(10): try: await self._nina.update() except ApiError as err: @@ -147,6 +162,21 @@ class NINADataUpdateCoordinator( if re.search( self.headline_filter, raw_warn.headline, flags=re.IGNORECASE ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by headline filter ({self.headline_filter}) with headline: {raw_warn.headline}" + ) + continue + + affected_areas_string: str = ", ".join( + [str(area) for area in raw_warn.affected_areas] + ) + + if not re.search( + self.area_filter, affected_areas_string, flags=re.IGNORECASE + ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by area filter ({self.area_filter}) with area: {affected_areas_string}" + ) continue warning_data: NinaWarningData = NinaWarningData( @@ -156,6 +186,7 @@ class NINADataUpdateCoordinator( raw_warn.sender, raw_warn.severity, " ".join([str(action) for action in raw_warn.recommended_actions]), + affected_areas_string, raw_warn.sent or "", raw_warn.start or "", raw_warn.expires or "", diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 24d6d35d0e8..19f802f1cec 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NINADataUpdateCoordinator from .const import ( + ATTR_AFFECTED_AREAS, ATTR_DESCRIPTION, ATTR_EXPIRES, ATTR_HEADLINE, @@ -73,7 +74,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti @property def is_on(self) -> bool: """Return the state of the sensor.""" - if not len(self.coordinator.data[self._region]) > self._warning_index: + if len(self.coordinator.data[self._region]) <= self._warning_index: return False data = self.coordinator.data[self._region][self._warning_index] @@ -94,6 +95,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti ATTR_SENDER: data.sender, ATTR_SEVERITY: data.severity, ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, + ATTR_AFFECTED_AREAS: data.affected_areas, ATTR_ID: data.id, ATTR_SENT: data.sent, ATTR_START: data.start, diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index d41fa6dee3e..9c6de40ac6b 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_registry import ( from .const import ( _LOGGER, + CONF_AREA_FILTER, CONF_HEADLINE_FILTER, CONF_MESSAGE_SLOTS, CONF_REGIONS, @@ -263,6 +264,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_HEADLINE_FILTER, default=self.data[CONF_HEADLINE_FILTER], ): cv.string, + vol.Optional( + CONF_AREA_FILTER, + default=self.data[CONF_AREA_FILTER], + ): cv.string, } ), errors=errors, diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 36096d97dc1..198e21c2689 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -12,17 +12,20 @@ SCAN_INTERVAL: timedelta = timedelta(minutes=5) DOMAIN: str = "nina" NO_MATCH_REGEX: str = "/(?!)/" +ALL_MATCH_REGEX: str = ".*" CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" CONF_FILTER_CORONA: str = "corona_filter" # deprecated CONF_HEADLINE_FILTER: str = "headline_filter" +CONF_AREA_FILTER: str = "area_filter" ATTR_HEADLINE: str = "headline" ATTR_DESCRIPTION: str = "description" ATTR_SENDER: str = "sender" ATTR_SEVERITY: str = "severity" ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions" +ATTR_AFFECTED_AREAS: str = "affected_areas" ATTR_ID: str = "id" ATTR_SENT: str = "sent" ATTR_START: str = "start" diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index e145f5ea8ca..5e0393d024f 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -36,7 +36,8 @@ "_r_to_u": "[%key:component::nina::config::step::user::data::_r_to_u%]", "_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]", "slots": "[%key:component::nina::config::step::user::data::slots%]", - "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]" + "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]", + "area_filter": "Whitelist regex to filter warnings based on affected areas" } } }, diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index 6688888df01..e91b5cec92d 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -6,7 +6,6 @@ import logging import aiohttp from aiohttp.hdrs import AUTHORIZATION, USER_AGENT -import async_timeout import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME @@ -100,7 +99,7 @@ async def _update_no_ip( } try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): resp = await session.get(url, params=params, headers=headers) body = await resp.text() diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 00667c43fdb..e3cfa04802c 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -17,13 +17,9 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - PRECISION_TENTHS, - UnitOfTemperature, -) +from homeassistant.const import ATTR_NAME, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 9cc957ec1df..3446f1ea43b 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -9,13 +9,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_MODEL, - ATTR_NAME, - UnitOfTemperature, -) +from homeassistant.const import ATTR_MODEL, ATTR_NAME, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index 4a3fc7cee96..84af1313cf5 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["PyMetno==0.10.0"] + "requirements": ["PyMetno==0.11.0"] } diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 258f14056ca..88605fdbdfd 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -31,7 +31,8 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index b2bc66b60c0..18b34ea0bea 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event as event_helper -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index d237303e7c9..3b846d73477 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,6 +1,7 @@ """The nuki component.""" from __future__ import annotations +import asyncio from collections import defaultdict from datetime import timedelta from http import HTTPStatus @@ -8,7 +9,6 @@ import logging from typing import Generic, TypeVar from aiohttp import web -import async_timeout from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException from pynuki.device import NukiDevice @@ -30,7 +30,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -126,7 +126,7 @@ async def _create_webhook( ir.async_delete_issue(hass, DOMAIN, "https_webhook") try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job( _register_webhook, bridge, entry.entry_id, url ) @@ -216,7 +216,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Stop and remove the Nuki webhook.""" webhook.async_unregister(hass, entry.entry_id) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, bridge, entry.entry_id ) @@ -252,7 +252,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" webhook.async_unregister(hass, entry.entry_id) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], @@ -301,7 +301,7 @@ class NukiCoordinator(DataUpdateCoordinator[None]): try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): events = await self.hass.async_add_executor_job( self.update_devices, self.locks + self.openers ) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 6bf5b68e927..8b0d8fe4640 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -1,12 +1,12 @@ """The nut component.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import timedelta import logging from typing import cast -import async_timeout from pynut2.nut2 import PyNUTClient, PyNUTError from homeassistant.config_entries import ConfigEntry @@ -65,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> dict[str, str]: """Fetch data from NUT.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await hass.async_add_executor_job(data.update) if not data.status: raise UpdateFailed("Error fetching UPS state") @@ -77,6 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="NUT resource status", update_method=async_update_data, update_interval=timedelta(seconds=scan_interval), + always_update=False, ) # Fetch initial data so we have data when entities subscribe diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 6574577558e..9151a86a9f8 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index ef0731ee94c..063ecdabab2 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from dataclasses import dataclass import datetime import logging from typing import TYPE_CHECKING @@ -13,21 +14,12 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Pla from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import debounce from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.dt import utcnow -from .const import ( - CONF_STATION, - COORDINATOR_FORECAST, - COORDINATOR_FORECAST_HOURLY, - COORDINATOR_OBSERVATION, - DOMAIN, - NWS_DATA, - UPDATE_TIME_PERIOD, -) +from .const import CONF_STATION, DOMAIN, UPDATE_TIME_PERIOD _LOGGER = logging.getLogger(__name__) @@ -43,7 +35,17 @@ def base_unique_id(latitude: float, longitude: float) -> str: return f"{latitude}_{longitude}" -class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): +@dataclass +class NWSData: + """Data for the National Weather Service integration.""" + + api: SimpleNWS + coordinator_observation: NwsDataUpdateCoordinator + coordinator_forecast: NwsDataUpdateCoordinator + coordinator_forecast_hourly: NwsDataUpdateCoordinator + + +class NwsDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): """NWS data update coordinator. Implements faster data update intervals for failed updates and exposes a last successful update time. @@ -70,7 +72,6 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): request_refresh_debouncer=request_refresh_debouncer, ) self.failed_update_interval = failed_update_interval - self.last_update_success_time: datetime.datetime | None = None @callback def _schedule_refresh(self) -> None: @@ -88,7 +89,6 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): # the base class allows None, but this one doesn't assert self.update_interval is not None update_interval = self.update_interval - self.last_update_success_time = utcnow() else: update_interval = self.failed_update_interval self._unsub_refresh = async_track_point_in_utc_time( @@ -151,12 +151,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) nws_hass_data = hass.data.setdefault(DOMAIN, {}) - nws_hass_data[entry.entry_id] = { - NWS_DATA: nws_data, - COORDINATOR_OBSERVATION: coordinator_observation, - COORDINATOR_FORECAST: coordinator_forecast, - COORDINATOR_FORECAST_HOURLY: coordinator_forecast_hourly, - } + nws_hass_data[entry.entry_id] = NWSData( + nws_data, + coordinator_observation, + coordinator_forecast, + coordinator_forecast_hourly, + ) # Fetch initial data so we have data when entities subscribe await coordinator_observation.async_refresh() diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index e5718d5132f..1e028649d89 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Final from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -25,7 +26,7 @@ CONF_STATION = "station" ATTRIBUTION = "Data from National Weather Service/NOAA" -ATTR_FORECAST_DETAILED_DESCRIPTION = "detailed_description" +ATTR_FORECAST_DETAILED_DESCRIPTION: Final = "detailed_description" CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_EXCEPTIONAL: [ @@ -74,11 +75,6 @@ CONDITION_CLASSES: dict[str, list[str]] = { DAYNIGHT = "daynight" HOURLY = "hourly" -NWS_DATA = "nws data" -COORDINATOR_OBSERVATION = "coordinator_observation" -COORDINATOR_FORECAST = "coordinator_forecast" -COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" - OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) # A lot of stations update once hourly plus some wiggle room diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 79a4294449b..7c49ca278a7 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -5,8 +5,6 @@ from dataclasses import dataclass from types import MappingProxyType from typing import Any -from pynws import SimpleNWS - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -25,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow @@ -36,15 +34,8 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import NwsDataUpdateCoordinator, base_unique_id, device_info -from .const import ( - ATTRIBUTION, - CONF_STATION, - COORDINATOR_OBSERVATION, - DOMAIN, - NWS_DATA, - OBSERVATION_VALID_TIME, -) +from . import NWSData, NwsDataUpdateCoordinator, base_unique_id, device_info +from .const import ATTRIBUTION, CONF_STATION, DOMAIN, OBSERVATION_VALID_TIME PARALLEL_UPDATES = 0 @@ -152,14 +143,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" - hass_data = hass.data[DOMAIN][entry.entry_id] + nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] station = entry.data[CONF_STATION] async_add_entities( NWSSensor( hass=hass, entry_data=entry.data, - hass_data=hass_data, + nws_data=nws_data, description=description, station=station, ) @@ -177,13 +168,13 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): self, hass: HomeAssistant, entry_data: MappingProxyType[str, Any], - hass_data: dict[str, Any], + nws_data: NWSData, description: NWSSensorEntityDescription, station: str, ) -> None: """Initialise the platform with a data instance.""" - super().__init__(hass_data[COORDINATOR_OBSERVATION]) - self._nws: SimpleNWS = hass_data[NWS_DATA] + super().__init__(nws_data.coordinator_observation) + self._nws = nws_data.api self._latitude = entry_data[CONF_LATITUDE] self._longitude = entry_data[CONF_LONGITUDE] self.entity_description = description diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 8ddf842cd62..0f594133f69 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -16,8 +16,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,25 +31,21 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter -from homeassistant.util.unit_system import UnitSystem -from . import base_unique_id, device_info +from . import NWSData, base_unique_id, device_info from .const import ( ATTR_FORECAST_DETAILED_DESCRIPTION, ATTRIBUTION, CONDITION_CLASSES, - COORDINATOR_FORECAST, - COORDINATOR_FORECAST_HOURLY, - COORDINATOR_OBSERVATION, DAYNIGHT, DOMAIN, FORECAST_VALID_TIME, HOURLY, - NWS_DATA, OBSERVATION_VALID_TIME, ) @@ -84,15 +82,20 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" - hass_data = hass.data[DOMAIN][entry.entry_id] + entity_registry = er.async_get(hass) + nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - NWSWeather(entry.data, hass_data, DAYNIGHT, hass.config.units), - NWSWeather(entry.data, hass_data, HOURLY, hass.config.units), - ], - False, - ) + entities = [NWSWeather(entry.data, nws_data, DAYNIGHT)] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(entry.data, HOURLY), + ): + entities.append(NWSWeather(entry.data, nws_data, HOURLY)) + + async_add_entities(entities, False) if TYPE_CHECKING: @@ -103,54 +106,91 @@ if TYPE_CHECKING: detailed_description: str | None -class NWSWeather(WeatherEntity): +def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str: + """Calculate unique ID.""" + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] + return f"{base_unique_id(latitude, longitude)}_{mode}" + + +class NWSWeather(CoordinatorWeatherEntity): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY + ) def __init__( self, entry_data: MappingProxyType[str, Any], - hass_data: dict[str, Any], + nws_data: NWSData, mode: str, - units: UnitSystem, ) -> None: """Initialise the platform with a data instance and station name.""" - self.nws = hass_data[NWS_DATA] + super().__init__( + observation_coordinator=nws_data.coordinator_observation, + hourly_coordinator=nws_data.coordinator_forecast_hourly, + twice_daily_coordinator=nws_data.coordinator_forecast, + hourly_forecast_valid=FORECAST_VALID_TIME, + twice_daily_forecast_valid=FORECAST_VALID_TIME, + ) + self.nws = nws_data.api self.latitude = entry_data[CONF_LATITUDE] self.longitude = entry_data[CONF_LONGITUDE] - self.coordinator_observation = hass_data[COORDINATOR_OBSERVATION] if mode == DAYNIGHT: - self.coordinator_forecast = hass_data[COORDINATOR_FORECAST] + self.coordinator_forecast_legacy = nws_data.coordinator_forecast else: - self.coordinator_forecast = hass_data[COORDINATOR_FORECAST_HOURLY] + self.coordinator_forecast_legacy = nws_data.coordinator_forecast_hourly self.station = self.nws.station self.mode = mode - self.observation = None - self._forecast = None + self.observation: dict[str, Any] | None = None + self._forecast_hourly: list[dict[str, Any]] | None = None + self._forecast_legacy: list[dict[str, Any]] | None = None + self._forecast_twice_daily: list[dict[str, Any]] | None = None + + self._attr_unique_id = _calculate_unique_id(entry_data, mode) async def async_added_to_hass(self) -> None: """Set up a listener and load data.""" + await super().async_added_to_hass() self.async_on_remove( - self.coordinator_observation.async_add_listener(self._update_callback) + self.coordinator_forecast_legacy.async_add_listener( + self._handle_legacy_forecast_coordinator_update + ) ) - self.async_on_remove( - self.coordinator_forecast.async_add_listener(self._update_callback) - ) - self._update_callback() + # Load initial data from coordinators + self._handle_coordinator_update() + self._handle_hourly_forecast_coordinator_update() + self._handle_twice_daily_forecast_coordinator_update() + self._handle_legacy_forecast_coordinator_update() @callback - def _update_callback(self) -> None: + def _handle_coordinator_update(self) -> None: """Load data from integration.""" self.observation = self.nws.observation - if self.mode == DAYNIGHT: - self._forecast = self.nws.forecast - else: - self._forecast = self.nws.forecast_hourly + self.async_write_ha_state() + @callback + def _handle_hourly_forecast_coordinator_update(self) -> None: + """Handle updated data from the hourly forecast coordinator.""" + self._forecast_hourly = self.nws.forecast_hourly + + @callback + def _handle_twice_daily_forecast_coordinator_update(self) -> None: + """Handle updated data from the twice daily forecast coordinator.""" + self._forecast_twice_daily = self.nws.forecast + + @callback + def _handle_legacy_forecast_coordinator_update(self) -> None: + """Handle updated data from the legacy forecast coordinator.""" + if self.mode == DAYNIGHT: + self._forecast_legacy = self.nws.forecast + else: + self._forecast_legacy = self.nws.forecast_hourly self.async_write_ha_state() @property @@ -214,7 +254,7 @@ class NWSWeather(WeatherEntity): weather = None if self.observation: weather = self.observation.get("iconWeather") - time = self.observation.get("iconTime") + time = cast(str, self.observation.get("iconTime")) if weather: return convert_condition(time, weather) @@ -232,18 +272,19 @@ class NWSWeather(WeatherEntity): """Return visibility unit.""" return UnitOfLength.METERS - @property - def forecast(self) -> list[Forecast] | None: + def _forecast( + self, nws_forecast: list[dict[str, Any]] | None, mode: str + ) -> list[Forecast] | None: """Return forecast.""" - if self._forecast is None: + if nws_forecast is None: return None - forecast: list[NWSForecast] = [] - for forecast_entry in self._forecast: - data = { + forecast: list[Forecast] = [] + for forecast_entry in nws_forecast: + data: NWSForecast = { ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get( "detailedForecast" ), - ATTR_FORECAST_TIME: forecast_entry.get("startTime"), + ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")), } if (temp := forecast_entry.get("temperature")) is not None: @@ -266,7 +307,7 @@ class NWSWeather(WeatherEntity): data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity") - if self.mode == DAYNIGHT: + if mode == DAYNIGHT: data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime") time = forecast_entry.get("iconTime") @@ -289,25 +330,35 @@ class NWSWeather(WeatherEntity): return forecast @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{base_unique_id(self.latitude, self.longitude)}_{self.mode}" + def forecast(self) -> list[Forecast] | None: + """Return forecast.""" + return self._forecast(self._forecast_legacy, self.mode) + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return self._forecast(self._forecast_hourly, HOURLY) + + @callback + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + return self._forecast(self._forecast_twice_daily, DAYNIGHT) @property def available(self) -> bool: """Return if state is available.""" last_success = ( - self.coordinator_observation.last_update_success - and self.coordinator_forecast.last_update_success + self.coordinator.last_update_success + and self.coordinator_forecast_legacy.last_update_success ) if ( - self.coordinator_observation.last_update_success_time - and self.coordinator_forecast.last_update_success_time + self.coordinator.last_update_success_time + and self.coordinator_forecast_legacy.last_update_success_time ): last_success_time = ( - utcnow() - self.coordinator_observation.last_update_success_time + utcnow() - self.coordinator.last_update_success_time < OBSERVATION_VALID_TIME - and utcnow() - self.coordinator_forecast.last_update_success_time + and utcnow() - self.coordinator_forecast_legacy.last_update_success_time < FORECAST_VALID_TIME ) else: @@ -319,8 +370,8 @@ class NWSWeather(WeatherEntity): Only used by the generic entity update service. """ - await self.coordinator_observation.async_request_refresh() - await self.coordinator_forecast.async_request_refresh() + await self.coordinator.async_request_refresh() + await self.coordinator_forecast_legacy.async_request_refresh() @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index c5512076172..c3b6aab619b 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -5,6 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -107,15 +108,20 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non class NZBGetEntity(CoordinatorEntity[NZBGetDataUpdateCoordinator]): """Defines a base NZBGet entity.""" + _attr_has_entity_name = True + def __init__( - self, *, entry_id: str, name: str, coordinator: NZBGetDataUpdateCoordinator + self, + *, + entry_id: str, + entry_name: str, + coordinator: NZBGetDataUpdateCoordinator, ) -> None: """Initialize the NZBGet entity.""" super().__init__(coordinator) - self._name = name self._entry_id = entry_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name=entry_name, + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index c037619d31b..7326fa50dd5 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -1,10 +1,10 @@ """Provides the NZBGet DataUpdateCoordinator.""" +import asyncio from collections.abc import Mapping from datetime import timedelta import logging from typing import Any -from async_timeout import timeout from pynzbgetapi import NZBGetAPI, NZBGetAPIException from homeassistant.const import ( @@ -96,7 +96,7 @@ class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): } try: - async with timeout(4): + async with asyncio.timeout(4): return await self.hass.async_add_executor_job(_update_data) except NZBGetAPIException as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 6d94ef35456..d76e004d720 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import NZBGetEntity @@ -24,63 +25,66 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="ArticleCacheMB", - name="Article Cache", + translation_key="article_cache", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, ), SensorEntityDescription( key="AverageDownloadRate", - name="Average Speed", + translation_key="average_speed", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, ), SensorEntityDescription( key="DownloadPaused", - name="Download Paused", + translation_key="download_paused", ), SensorEntityDescription( key="DownloadRate", - name="Speed", + translation_key="speed", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, ), SensorEntityDescription( key="DownloadedSizeMB", - name="Size", + translation_key="size", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, ), SensorEntityDescription( key="FreeDiskSpaceMB", - name="Disk Free", + translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, ), SensorEntityDescription( key="PostJobCount", - name="Post Processing Jobs", + translation_key="post_processing_jobs", native_unit_of_measurement="Jobs", ), SensorEntityDescription( key="PostPaused", - name="Post Processing Paused", + translation_key="post_processing_paused", ), SensorEntityDescription( key="RemainingSizeMB", - name="Queue Size", + translation_key="queue_size", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, ), SensorEntityDescription( key="UpTimeSec", - name="Uptime", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="DownloadLimit", - name="Speed Limit", + translation_key="speed_limit", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, ), ) @@ -116,35 +120,22 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): super().__init__( coordinator=coordinator, entry_id=entry_id, - name=f"{entry_name} {description.name}", + entry_name=entry_name, ) self.entity_description = description self._attr_unique_id = f"{entry_id}_{description.key}" - self._native_value: datetime | None = None @property - def native_value(self): + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" sensor_type = self.entity_description.key value = self.coordinator.data["status"].get(sensor_type) - if value is None: - _LOGGER.warning("Unable to locate value for %s", sensor_type) - self._native_value = None - elif "DownloadRate" in sensor_type and value > 0: - # Convert download rate from Bytes/s to MBytes/s - self._native_value = round(value / 2**20, 2) - elif "DownloadLimit" in sensor_type and value > 0: - # Convert download rate from Bytes/s to MBytes/s - self._native_value = round(value / 2**20, 2) - elif "UpTimeSec" in sensor_type and value > 0: + if value is not None and "UpTimeSec" in sensor_type and value > 0: uptime = utcnow().replace(microsecond=0) - timedelta(seconds=value) if not isinstance(self._attr_native_value, datetime) or abs( uptime - self._attr_native_value ) > timedelta(seconds=5): - self._native_value = uptime - else: - self._native_value = value - - return self._native_value + return uptime + return value diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index 7a3c438d11f..a1faa63bb39 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -32,6 +32,48 @@ } } }, + "entity": { + "sensor": { + "article_cache": { + "name": "Article cache" + }, + "average_speed": { + "name": "Average speed" + }, + "download_paused": { + "name": "Download paused" + }, + "speed": { + "name": "Speed" + }, + "size": { + "name": "Size" + }, + "disk_free": { + "name": "Disk free" + }, + "post_processing_jobs": { + "name": "Post processing jobs" + }, + "post_processing_paused": { + "name": "Post processing paused" + }, + "queue_size": { + "name": "Queue size" + }, + "uptime": { + "name": "Uptime" + }, + "speed_limit": { + "name": "Speed limit" + } + }, + "switch": { + "download": { + "name": "Download" + } + } + }, "services": { "pause": { "name": "[%key:common::action::pause%]", diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index 74b49b63501..e6a2b213873 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -38,6 +38,8 @@ async def async_setup_entry( class NZBGetDownloadSwitch(NZBGetEntity, SwitchEntity): """Representation of a NZBGet download switch.""" + _attr_translation_key = "download" + def __init__( self, coordinator: NZBGetDataUpdateCoordinator, @@ -50,7 +52,7 @@ class NZBGetDownloadSwitch(NZBGetEntity, SwitchEntity): super().__init__( coordinator=coordinator, entry_id=entry_id, - name=f"{entry_name} Download", + entry_name=entry_name, ) @property diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 790b332dbfd..07b2fa1a15d 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify as util_slugify diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 0d403c3ec87..578554da5bd 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -5,7 +5,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 9c3049ff87d..99052993a61 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -7,7 +7,7 @@ from homeassistant.components.mjpeg.camera import MjpegCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import OctoprintDataUpdateCoordinator diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index 4ef78477afe..4e64a219f77 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -8,7 +8,7 @@ from omnilogic import OmniLogic, OmniLogicException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 5b47394e0e4..05467e96860 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -193,7 +193,12 @@ class CoreConfigOnboardingView(_BaseOnboardingView): await self._async_mark_done(hass) # Integrations to set up when finishing onboarding - onboard_integrations = ["google_translate", "met", "radio_browser"] + onboard_integrations = [ + "google_translate", + "met", + "radio_browser", + "shopping_list", + ] # pylint: disable-next=import-outside-toplevel from homeassistant.components import hassio diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index eb9ac37db18..a87b2a9e02c 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -39,6 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name=f"Oncue {entry.data[CONF_USERNAME]}", update_interval=timedelta(minutes=10), update_method=client.async_fetch_all, + always_update=False, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/oncue/entity.py b/homeassistant/components/oncue/entity.py index 9ec37c98d73..6d988d4aaaf 100644 --- a/homeassistant/components/oncue/entity.py +++ b/homeassistant/components/oncue/entity.py @@ -5,7 +5,8 @@ from aiooncue import OncueDevice, OncueSensor from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -19,6 +20,8 @@ class OncueEntity( ): """Representation of an Oncue entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], @@ -32,7 +35,7 @@ class OncueEntity( self.entity_description = description self._device_id = device_id self._attr_unique_id = f"{device_id}_{description.key}" - self._attr_name = f"{device.name} {sensor.display_name}" + self._attr_name = sensor.display_name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, name=device.name, diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 8b4cfcb61a4..4345f3498fd 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/onewire/model.py b/homeassistant/components/onewire/model.py index d3fb2f22f14..6e134fd8466 100644 --- a/homeassistant/components/onewire/model.py +++ b/homeassistant/components/onewire/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo @dataclass diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index f2a56e513f2..a6eddece5c6 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -7,7 +7,8 @@ from typing import Any from pyownet import protocol -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from .const import READ_MODE_BOOL, READ_MODE_INT diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index a412f87deaa..d0e2a0f1706 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import ( DEVICE_SUPPORT, diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 65bd542fc30..34ed66bd511 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -73,7 +73,6 @@ SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION = OneWireSensorEntityDescription( native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ) _LOGGER = logging.getLogger(__name__) @@ -89,7 +88,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), OneWireSensorEntityDescription( key="TAI8570/pressure", @@ -98,7 +96,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), ), "22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,), @@ -111,7 +108,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="humidity", ), OneWireSensorEntityDescription( key="HIH3600/humidity", @@ -156,7 +152,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), OneWireSensorEntityDescription( key="S3-R1-A/illuminance", @@ -165,7 +160,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="illuminance", ), OneWireSensorEntityDescription( key="VAD", @@ -203,7 +197,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { override_key=_get_sensor_precision_family_28, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), ), "30": ( @@ -225,7 +218,6 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfElectricPotential.VOLT, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="voltage", ), OneWireSensorEntityDescription( key="vis", @@ -261,7 +253,6 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="humidity", ), OneWireSensorEntityDescription( key="humidity/humidity_raw", @@ -277,7 +268,6 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), ), "HB_MOISTURE_METER": tuple( @@ -303,7 +293,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0066/pressure", @@ -311,7 +300,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), ), "EDS0068": ( @@ -321,7 +309,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="temperature", ), OneWireSensorEntityDescription( key="EDS0068/pressure", @@ -329,7 +316,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=UnitOfPressure.MBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="pressure", ), OneWireSensorEntityDescription( key="EDS0068/light", @@ -337,7 +323,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=LIGHT_LUX, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="illuminance", ), OneWireSensorEntityDescription( key="EDS0068/humidity", @@ -345,7 +330,6 @@ EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { native_unit_of_measurement=PERCENTAGE, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key="humidity", ), ), } diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index f58731a2377..9e4120b68b2 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -68,9 +68,6 @@ "counter_b": { "name": "Counter B" }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, "humidity_hih3600": { "name": "HIH3600 humidity" }, @@ -86,9 +83,6 @@ "humidity_raw": { "name": "Raw humidity" }, - "illuminance": { - "name": "[%key:component::sensor::entity_component::illuminance::name%]" - }, "moisture_1": { "name": "Moisture 1" }, @@ -101,18 +95,9 @@ "moisture_4": { "name": "Moisture 4" }, - "pressure": { - "name": "[%key:component::sensor::entity_component::pressure::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, "voltage_vad": { "name": "VAD voltage" }, diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/base.py index 3b0f1efab38..8771ae7a701 100644 --- a/homeassistant/components/onvif/base.py +++ b/homeassistant/components/onvif/base.py @@ -1,8 +1,8 @@ """Base classes for ONVIF entities.""" from __future__ import annotations -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN from .device import ONVIFDevice diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 7a87ec66c83..96ce70344fd 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -114,6 +114,11 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self._stream_uri: str | None = None self._stream_uri_future: asyncio.Future[str] | None = None + @property + def use_stream_for_stills(self) -> bool: + """Whether or not to use stream to generate stills.""" + return bool(self.stream and self.stream.dynamic_stream_settings.preload_stream) + @property def name(self) -> str: """Return the name of this camera.""" @@ -140,9 +145,6 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): ) -> bytes | None: """Return a still image response from the camera.""" - if self.stream and self.stream.dynamic_stream_settings.preload_stream: - return await self.stream.async_get_image(width, height) - if self.device.capabilities.snapshot: try: if image := await self.device.device.get_snapshot( diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index b23abb54f8b..3d66422fd60 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -3,17 +3,17 @@ from __future__ import annotations from open_meteo import Forecast as OpenMeteoForecast -from homeassistant.components.weather import Forecast, WeatherEntity +from homeassistant.components.weather import ( + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, WMO_TO_HA_CONDITION_MAP @@ -29,7 +29,7 @@ async def async_setup_entry( class OpenMeteoWeatherEntity( - CoordinatorEntity[DataUpdateCoordinator[OpenMeteoForecast]], WeatherEntity + SingleCoordinatorWeatherEntity[DataUpdateCoordinator[OpenMeteoForecast]] ): """Defines an Open-Meteo weather entity.""" @@ -38,6 +38,7 @@ class OpenMeteoWeatherEntity( _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY def __init__( self, @@ -122,3 +123,8 @@ class OpenMeteoWeatherEntity( forecasts.append(forecast) return forecasts + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.forecast diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index aa1e5ecbc0a..64b46a1da94 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -7,7 +7,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.image_processing import ( @@ -199,7 +198,7 @@ class OpenAlprCloudEntity(ImageProcessingAlprEntity): body = {"image_bytes": str(b64encode(image), "utf-8")} try: - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): request = await websession.post( OPENALPR_API_URL, params=params, data=body ) diff --git a/homeassistant/components/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py index 41738100cab..89c1a16aa59 100644 --- a/homeassistant/components/opencv/image_processing.py +++ b/homeassistant/components/opencv/image_processing.py @@ -188,7 +188,6 @@ class OpenCVImageProcessor(ImageProcessingEntity): cv_image, scaleFactor=scale, minNeighbors=neighbors, minSize=min_size ) regions = [] - # pylint: disable=invalid-name for x, y, w, h in detections: regions.append((int(x), int(y), int(w), int(h))) total_matches += 1 diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index 13060e19718..a61264dbf41 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -10,7 +10,6 @@ from aioopenexchangerates import ( OpenExchangeRatesAuthError, OpenExchangeRatesClientError, ) -import async_timeout import voluptuous as vol from homeassistant import config_entries @@ -40,7 +39,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, """Validate the user input allows us to connect.""" client = Client(data[CONF_API_KEY], async_get_clientsession(hass)) - async with async_timeout.timeout(CLIENT_TIMEOUT): + async with asyncio.timeout(CLIENT_TIMEOUT): await client.get_latest(base=data[CONF_BASE]) return {"title": data[CONF_BASE]} @@ -119,7 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not self.currencies: client = Client("dummy-api-key", async_get_clientsession(self.hass)) try: - async with async_timeout.timeout(CLIENT_TIMEOUT): + async with asyncio.timeout(CLIENT_TIMEOUT): self.currencies = await client.get_currencies() except OpenExchangeRatesClientError as err: raise AbortFlow("cannot_connect") from err diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 3795f33aec5..beb588c7ce6 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -1,6 +1,7 @@ """Provide an OpenExchangeRates data coordinator.""" from __future__ import annotations +import asyncio from datetime import timedelta from aiohttp import ClientSession @@ -10,7 +11,6 @@ from aioopenexchangerates import ( OpenExchangeRatesAuthError, OpenExchangeRatesClientError, ) -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -40,7 +40,7 @@ class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): async def _async_update_data(self) -> Latest: """Update data from Open Exchange Rates.""" try: - async with async_timeout.timeout(CLIENT_TIMEOUT): + async with asyncio.timeout(CLIENT_TIMEOUT): latest = await self.client.get_latest(base=self.base) except OpenExchangeRatesAuthError as err: raise ConfigEntryAuthFailed(err) from err diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index f73f78cb4e8..70f2f670de8 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -3,10 +3,9 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_QUOTE +from homeassistant.const import CONF_QUOTE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -22,14 +21,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Open Exchange Rates sensor.""" - # Only YAML imported configs have name and quote in config entry data. - name: str | None = config_entry.data.get(CONF_NAME) quote: str = config_entry.data.get(CONF_QUOTE, "EUR") coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( OpenexchangeratesSensor( - config_entry, coordinator, name, rate_quote, rate_quote == quote + config_entry, coordinator, rate_quote, rate_quote == quote ) for rate_quote in coordinator.data.rates ) @@ -40,13 +37,13 @@ class OpenexchangeratesSensor( ): """Representation of an Open Exchange Rates sensor.""" + _attr_has_entity_name = True _attr_attribution = ATTRIBUTION def __init__( self, config_entry: ConfigEntry, coordinator: OpenexchangeratesCoordinator, - name: str | None, quote: str, enabled: bool, ) -> None: @@ -59,14 +56,7 @@ class OpenexchangeratesSensor( name=f"Open Exchange Rates {coordinator.base}", ) self._attr_entity_registry_enabled_default = enabled - if name and enabled: - # name is legacy imported from YAML config - # this block can be removed when removing import from YAML - self._attr_name = name - self._attr_has_entity_name = False - else: - self._attr_name = quote - self._attr_has_entity_name = True + self._attr_name = quote self._attr_native_unit_of_measurement = quote self._attr_unique_id = f"{config_entry.entry_id}_{quote}" self._quote = quote diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index 64bc7c83d20..22f118ca804 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="vehicle", + translation_key="vehicle", ), ) @@ -66,9 +67,6 @@ class OpenGarageBinarySensor(OpenGarageEntity, BinarySensorEntity): @callback def _update_attr(self) -> None: """Handle updated data from the coordinator.""" - self._attr_name = ( - f'{self.coordinator.data["name"]} {self.entity_description.key}' - ) state = self.coordinator.data.get(self.entity_description.key) if state == 1: self._attr_is_on = True diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 15669a41736..3f3f6b11acf 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -37,6 +37,7 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + _attr_name = None def __init__( self, coordinator: OpenGarageDataUpdateCoordinator, device_id: str @@ -89,7 +90,6 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): """Update the state and attributes.""" status = self.coordinator.data - self._attr_name = status["name"] state = STATES_MAP.get(status.get("door")) # type: ignore[arg-type] if self._state_before_move is not None: if self._state_before_move != state: diff --git a/homeassistant/components/opengarage/entity.py b/homeassistant/components/opengarage/entity.py index dec0d1daae8..c8380ea9244 100644 --- a/homeassistant/components/opengarage/entity.py +++ b/homeassistant/components/opengarage/entity.py @@ -2,8 +2,8 @@ from __future__ import annotations from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, OpenGarageDataUpdateCoordinator @@ -12,6 +12,8 @@ from . import DOMAIN, OpenGarageDataUpdateCoordinator class OpenGarageEntity(CoordinatorEntity[OpenGarageDataUpdateCoordinator]): """Representation of a OpenGarage entity.""" + _attr_has_entity_name = True + def __init__( self, open_garage_data_coordinator: OpenGarageDataUpdateCoordinator, diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index 796192b406f..b1d6cb921fa 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -83,7 +83,4 @@ class OpenGarageSensor(OpenGarageEntity, SensorEntity): @callback def _update_attr(self) -> None: """Handle updated data from the coordinator.""" - self._attr_name = ( - f'{self.coordinator.data["name"]} {self.entity_description.key}' - ) self._attr_native_value = self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index 26f2f94ff9f..ba4521d4dcf 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -18,5 +18,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "vehicle": { + "name": "Vehicle" + } + } } } diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 77ab0ac0aaf..efc6ab37f21 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -23,7 +23,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN @@ -106,18 +106,11 @@ class OpenhomeDevice(MediaPlayerEntity): """Initialise the Openhome device.""" self.hass = hass self._device = device - self._track_information = {} - self._in_standby = None - self._transport_state = None - self._volume_level = None - self._volume_muted = None + self._attr_unique_id = device.uuid() self._attr_supported_features = SUPPORT_OPENHOME - self._source_names = [] self._source_index = {} - self._source = {} - self._name = None self._attr_state = MediaPlayerState.PLAYING - self._available = True + self._attr_available = True @property def device_info(self): @@ -131,47 +124,47 @@ class OpenhomeDevice(MediaPlayerEntity): name=self._device.friendly_name(), ) - @property - def available(self): - """Device is available.""" - return self._available - async def async_update(self) -> None: """Update state of device.""" try: - self._in_standby = await self._device.is_in_standby() - self._transport_state = await self._device.transport_state() - self._track_information = await self._device.track_info() - self._source = await self._device.source() - self._name = await self._device.room() + self._attr_name = await self._device.room() self._attr_supported_features = SUPPORT_OPENHOME source_index = {} source_names = [] + track_information = await self._device.track_info() + self._attr_media_image_url = track_information.get("albumArtwork") + self._attr_media_album_name = track_information.get("albumTitle") + self._attr_media_title = track_information.get("title") + if artists := track_information.get("artist"): + self._attr_media_artist = artists[0] + if self._device.volume_enabled: self._attr_supported_features |= ( MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET ) - self._volume_level = await self._device.volume() / 100.0 - self._volume_muted = await self._device.is_muted() + self._attr_volume_level = await self._device.volume() / 100.0 + self._attr_is_volume_muted = await self._device.is_muted() for source in await self._device.sources(): source_names.append(source["name"]) source_index[source["name"]] = source["index"] + source = await self._device.source() + self._attr_source = source.get("name") self._source_index = source_index - self._source_names = source_names + self._attr_source_list = source_names - if self._source["type"] == "Radio": + if source["type"] == "Radio": self._attr_supported_features |= ( MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA ) - if self._source["type"] in ("Playlist", "Spotify"): + if source["type"] in ("Playlist", "Spotify"): self._attr_supported_features |= ( MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK @@ -181,21 +174,23 @@ class OpenhomeDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) - if self._in_standby: + in_standby = await self._device.is_in_standby() + transport_state = await self._device.transport_state() + if in_standby: self._attr_state = MediaPlayerState.OFF - elif self._transport_state == "Paused": + elif transport_state == "Paused": self._attr_state = MediaPlayerState.PAUSED - elif self._transport_state in ("Playing", "Buffering"): + elif transport_state in ("Playing", "Buffering"): self._attr_state = MediaPlayerState.PLAYING - elif self._transport_state == "Stopped": + elif transport_state == "Stopped": self._attr_state = MediaPlayerState.IDLE else: # Device is playing an external source with no transport controls self._attr_state = MediaPlayerState.PLAYING - self._available = True + self._attr_available = True except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError): - self._available = False + self._attr_available = False @catch_request_errors() async def async_turn_on(self) -> None: @@ -273,57 +268,6 @@ class OpenhomeDevice(MediaPlayerEntity): except UpnpError: _LOGGER.error("Error invoking pin %s", pin) - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._device.uuid() - - @property - def source_list(self): - """List of available input sources.""" - return self._source_names - - @property - def media_image_url(self): - """Image url of current playing media.""" - return self._track_information.get("albumArtwork") - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - if artists := self._track_information.get("artist"): - return artists[0] - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self._track_information.get("albumTitle") - - @property - def media_title(self): - """Title of current playing media.""" - return self._track_information.get("title") - - @property - def source(self): - """Name of the current input source.""" - return self._source.get("name") - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume_level - - @property - def is_volume_muted(self): - """Return true if volume is muted.""" - return self._volume_muted - @catch_request_errors() async def async_volume_up(self) -> None: """Volume up media player.""" diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index 54c2d16fb2b..9013e50030f 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index 197356b2092..cb9c6173694 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -1,22 +1,42 @@ """The opensky component.""" from __future__ import annotations +from aiohttp import BasicAuth from python_opensky import OpenSky +from python_opensky.exceptions import OpenSkyUnauthenticatedError 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 CLIENT, DOMAIN, PLATFORMS +from .const import CONF_CONTRIBUTING_USER, DOMAIN, PLATFORMS +from .coordinator import OpenSkyDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up opensky from a config entry.""" client = OpenSky(session=async_get_clientsession(hass)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {CLIENT: client} + if CONF_USERNAME in entry.options and CONF_PASSWORD in entry.options: + try: + await client.authenticate( + BasicAuth( + login=entry.options[CONF_USERNAME], + password=entry.options[CONF_PASSWORD], + ), + contributing_user=entry.options.get(CONF_CONTRIBUTING_USER, False), + ) + except OpenSkyUnauthenticatedError as exc: + raise ConfigEntryNotReady from exc + + coordinator = OpenSkyDataUpdateCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -25,3 +45,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload opensky config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 6e3ffb5e2b1..a0cd6bc54c2 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -3,26 +3,45 @@ from __future__ import annotations from typing import Any +from aiohttp import BasicAuth +from python_opensky import OpenSky +from python_opensky.exceptions import OpenSkyUnauthenticatedError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + CONF_PASSWORD, CONF_RADIUS, + 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 homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_NAME, DOMAIN +from .const import CONF_CONTRIBUTING_USER, DEFAULT_NAME, DOMAIN from .sensor import CONF_ALTITUDE, DEFAULT_ALTITUDE class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow handler for OpenSky.""" + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OpenSkyOptionsFlowHandler: + """Get the options flow for this handler.""" + return OpenSkyOptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -75,3 +94,57 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): CONF_ALTITUDE: import_config.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), }, ) + + +class OpenSkyOptionsFlowHandler(OptionsFlowWithConfigEntry): + """OpenSky Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + errors: dict[str, str] = {} + if user_input is not None: + authentication = CONF_USERNAME in user_input or CONF_PASSWORD in user_input + if authentication and CONF_USERNAME not in user_input: + errors["base"] = "username_missing" + if authentication and CONF_PASSWORD not in user_input: + errors["base"] = "password_missing" + if user_input[CONF_CONTRIBUTING_USER] and not authentication: + errors["base"] = "no_authentication" + if authentication and not errors: + async with OpenSky( + session=async_get_clientsession(self.hass) + ) as opensky: + try: + await opensky.authenticate( + BasicAuth( + login=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ), + contributing_user=user_input[CONF_CONTRIBUTING_USER], + ) + except OpenSkyUnauthenticatedError: + errors["base"] = "invalid_auth" + if not errors: + return self.async_create_entry( + title=self.options.get(CONF_NAME, "OpenSky"), + data=user_input, + ) + + return self.async_show_form( + step_id="init", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Optional(CONF_ALTITUDE): vol.Coerce(float), + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_CONTRIBUTING_USER, default=False): bool, + } + ), + user_input or self.options, + ), + ) diff --git a/homeassistant/components/opensky/const.py b/homeassistant/components/opensky/const.py index ccea69f8b7f..7fe26b424d3 100644 --- a/homeassistant/components/opensky/const.py +++ b/homeassistant/components/opensky/const.py @@ -1,12 +1,16 @@ """OpenSky constants.""" +import logging + from homeassistant.const import Platform +LOGGER = logging.getLogger(__package__) + PLATFORMS = [Platform.SENSOR] DEFAULT_NAME = "OpenSky" DOMAIN = "opensky" -CLIENT = "client" - +MANUFACTURER = "OpenSky Network" CONF_ALTITUDE = "altitude" +CONF_CONTRIBUTING_USER = "contributing_user" ATTR_ICAO24 = "icao24" ATTR_CALLSIGN = "callsign" ATTR_ALTITUDE = "altitude" diff --git a/homeassistant/components/opensky/coordinator.py b/homeassistant/components/opensky/coordinator.py new file mode 100644 index 00000000000..d85924737a1 --- /dev/null +++ b/homeassistant/components/opensky/coordinator.py @@ -0,0 +1,118 @@ +"""DataUpdateCoordinator for the OpenSky integration.""" +from __future__ import annotations + +from datetime import timedelta + +from python_opensky import OpenSky, OpenSkyError, StateVector + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_ALTITUDE, + ATTR_CALLSIGN, + ATTR_ICAO24, + ATTR_SENSOR, + CONF_ALTITUDE, + DEFAULT_ALTITUDE, + DOMAIN, + EVENT_OPENSKY_ENTRY, + EVENT_OPENSKY_EXIT, + LOGGER, +) + + +class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): + """An OpenSky Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, opensky: OpenSky) -> None: + """Initialize the OpenSky data coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval={ + True: timedelta(seconds=90), + False: timedelta(minutes=15), + }.get(opensky.is_authenticated), + ) + self._opensky = opensky + self._previously_tracked: set[str] | None = None + self._bounding_box = OpenSky.get_bounding_box( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + self.config_entry.options[CONF_RADIUS], + ) + self._altitude = self.config_entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE) + + async def _async_update_data(self) -> int: + try: + response = await self._opensky.get_states(bounding_box=self._bounding_box) + except OpenSkyError as exc: + raise UpdateFailed from exc + currently_tracked = set() + flight_metadata: dict[str, StateVector] = {} + for flight in response.states: + if not flight.callsign: + continue + callsign = flight.callsign.strip() + if callsign: + flight_metadata[callsign] = flight + else: + continue + if ( + flight.longitude is None + or flight.latitude is None + or flight.on_ground + or flight.barometric_altitude is None + ): + continue + altitude = flight.barometric_altitude + if altitude > self._altitude and self._altitude != 0: + continue + currently_tracked.add(callsign) + if self._previously_tracked is not None: + entries = currently_tracked - self._previously_tracked + exits = self._previously_tracked - currently_tracked + self._handle_boundary(entries, EVENT_OPENSKY_ENTRY, flight_metadata) + self._handle_boundary(exits, EVENT_OPENSKY_EXIT, flight_metadata) + self._previously_tracked = currently_tracked + + return len(currently_tracked) + + def _handle_boundary( + self, flights: set[str], event: str, metadata: dict[str, StateVector] + ) -> None: + """Handle flights crossing region boundary.""" + for flight in flights: + if flight in metadata: + altitude = metadata[flight].barometric_altitude + longitude = metadata[flight].longitude + latitude = metadata[flight].latitude + icao24 = metadata[flight].icao24 + else: + # Assume Flight has landed if missing. + altitude = 0 + longitude = None + latitude = None + icao24 = None + + data = { + ATTR_CALLSIGN: flight, + ATTR_ALTITUDE: altitude, + ATTR_SENSOR: self.config_entry.title, + ATTR_LONGITUDE: longitude, + ATTR_LATITUDE: latitude, + ATTR_ICAO24: icao24, + } + self.hass.bus.fire(event, data) diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index f3fb13589bb..4d1047222ff 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.0.10"] + "requirements": ["python-opensky==0.2.0"] } diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 4ef1070d12d..e6a165b36ee 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -1,43 +1,25 @@ """Sensor for the Open Sky Network.""" from __future__ import annotations -from datetime import timedelta - -from python_opensky import BoundingBox, OpenSky, StateVector import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, - CONF_RADIUS, +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.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 ( - ATTR_ALTITUDE, - ATTR_CALLSIGN, - ATTR_ICAO24, - ATTR_SENSOR, - CLIENT, - CONF_ALTITUDE, - DEFAULT_ALTITUDE, - DOMAIN, - EVENT_OPENSKY_ENTRY, - EVENT_OPENSKY_EXIT, -) - -# OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour -SCAN_INTERVAL = timedelta(minutes=15) - +from .const import CONF_ALTITUDE, DEFAULT_ALTITUDE, DOMAIN, MANUFACTURER +from .coordinator import OpenSkyDataUpdateCoordinator PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -87,125 +69,45 @@ async def async_setup_entry( ) -> None: """Initialize the entries.""" - opensky = hass.data[DOMAIN][entry.entry_id][CLIENT] - bounding_box = OpenSky.get_bounding_box( - entry.data[CONF_LATITUDE], - entry.data[CONF_LONGITUDE], - entry.options[CONF_RADIUS], - ) + coordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ OpenSkySensor( - entry.title, - opensky, - bounding_box, - entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), - entry.entry_id, + coordinator, + entry, ) ], - True, ) -class OpenSkySensor(SensorEntity): +class OpenSkySensor(CoordinatorEntity[OpenSkyDataUpdateCoordinator], SensorEntity): """Open Sky Network Sensor.""" _attr_attribution = ( "Information provided by the OpenSky Network (https://opensky-network.org)" ) + _attr_has_entity_name = True + _attr_name = None + _attr_icon = "mdi:airplane" + _attr_native_unit_of_measurement = "flights" + _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, - name: str, - opensky: OpenSky, - bounding_box: BoundingBox, - altitude: float, - entry_id: str, + coordinator: OpenSkyDataUpdateCoordinator, + config_entry: ConfigEntry, ) -> None: """Initialize the sensor.""" - self._altitude = altitude - self._state = 0 - self._name = name - self._previously_tracked: set[str] = set() - self._opensky = opensky - self._bounding_box = bounding_box - self._attr_unique_id = f"{entry_id}_opensky" - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry.entry_id}_opensky" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}")}, + manufacturer=MANUFACTURER, + name=config_entry.title, + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> int: """Return the state of the sensor.""" - return self._state - - def _handle_boundary( - self, flights: set[str], event: str, metadata: dict[str, StateVector] - ) -> None: - """Handle flights crossing region boundary.""" - for flight in flights: - if flight in metadata: - altitude = metadata[flight].barometric_altitude - longitude = metadata[flight].longitude - latitude = metadata[flight].latitude - icao24 = metadata[flight].icao24 - else: - # Assume Flight has landed if missing. - altitude = 0 - longitude = None - latitude = None - icao24 = None - - data = { - ATTR_CALLSIGN: flight, - ATTR_ALTITUDE: altitude, - ATTR_SENSOR: self._name, - ATTR_LONGITUDE: longitude, - ATTR_LATITUDE: latitude, - ATTR_ICAO24: icao24, - } - self.hass.bus.fire(event, data) - - async def async_update(self) -> None: - """Update device state.""" - currently_tracked = set() - flight_metadata: dict[str, StateVector] = {} - response = await self._opensky.get_states(bounding_box=self._bounding_box) - for flight in response.states: - if not flight.callsign: - continue - callsign = flight.callsign.strip() - if callsign != "": - flight_metadata[callsign] = flight - else: - continue - if ( - flight.longitude is None - or flight.latitude is None - or flight.on_ground - or flight.barometric_altitude is None - ): - continue - altitude = flight.barometric_altitude - if altitude > self._altitude and self._altitude != 0: - continue - currently_tracked.add(callsign) - if self._previously_tracked is not None: - entries = currently_tracked - self._previously_tracked - exits = self._previously_tracked - currently_tracked - self._handle_boundary(entries, EVENT_OPENSKY_ENTRY, flight_metadata) - self._handle_boundary(exits, EVENT_OPENSKY_EXIT, flight_metadata) - self._state = len(currently_tracked) - self._previously_tracked = currently_tracked - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return "flights" - - @property - def icon(self) -> str: - """Return the icon.""" - return "mdi:airplane" + return self.coordinator.data diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json index 768ffde155f..4b4dc908b14 100644 --- a/homeassistant/components/opensky/strings.json +++ b/homeassistant/components/opensky/strings.json @@ -4,7 +4,6 @@ "user": { "description": "Fill in the location to track.", "data": { - "name": "[%key:common::config_flow::data::api_key%]", "radius": "Radius", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", @@ -12,5 +11,25 @@ } } } + }, + "options": { + "step": { + "init": { + "description": "You can login to your OpenSky account to increase the update frequency.", + "data": { + "radius": "[%key:component::opensky::config::step::user::data::radius%]", + "altitude": "[%key:component::opensky::config::step::user::data::altitude%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "contributing_user": "I'm contributing to OpenSky" + } + } + }, + "error": { + "username_missing": "Username is missing", + "password_missing": "Password is missing", + "no_authentication": "You need to authenticate to be contributing", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } } } diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 3efe911b27f..0b8d4693cb8 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -3,7 +3,6 @@ import asyncio from datetime import date, datetime import logging -import async_timeout import pyotgw import pyotgw.vars as gw_vars from serial import SerialException @@ -113,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.add_update_listener(options_updated) try: - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): await gateway.connect_and_subscribe() except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: await gateway.cleanup() diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 1a4247992a7..2501d00c2eb 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -7,8 +7,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, async_generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 66223996180..b34239c933a 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -25,8 +25,9 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, async_generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 87a51021657..07187f3a2ec 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import async_timeout import pyotgw from pyotgw import vars as gw_vars from serial import SerialException @@ -69,7 +68,7 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) try: - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): await test_connection() except asyncio.TimeoutError: return self._show_form({"base": "timeout_connect"}) diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index d1f99461b22..b219969e71a 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -7,8 +7,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, async_generate_entity_id +from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index d53fbc136b2..1420b1170ca 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -157,3 +157,8 @@ CONDITION_CLASSES = { 904, ], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 2cfdd2456ab..232664d5b6b 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -21,8 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 DataUpdateCoordinator diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 30f98bb39d1..bf1ae5ca7da 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -17,7 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, Forecast, - WeatherEntity, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -26,9 +27,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -60,6 +60,8 @@ from .const import ( DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, + FORECAST_MODE_DAILY, + FORECAST_MODE_ONECALL_DAILY, MANUFACTURER, ) from .weather_update_coordinator import WeatherUpdateCoordinator @@ -96,7 +98,7 @@ async def async_setup_entry( async_add_entities([owm_weather], False) -class OpenWeatherMapWeather(WeatherEntity): +class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an OpenWeatherMap sensor.""" _attr_attribution = ATTRIBUTION @@ -114,6 +116,7 @@ class OpenWeatherMapWeather(WeatherEntity): weather_coordinator: WeatherUpdateCoordinator, ) -> None: """Initialize the sensor.""" + super().__init__(weather_coordinator) self._attr_name = name self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( @@ -122,62 +125,68 @@ class OpenWeatherMapWeather(WeatherEntity): manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) - self._weather_coordinator = weather_coordinator + if weather_coordinator.forecast_mode in ( + FORECAST_MODE_DAILY, + FORECAST_MODE_ONECALL_DAILY, + ): + self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + else: # FORECAST_MODE_DAILY or FORECAST_MODE_ONECALL_HOURLY + self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY @property def condition(self) -> str | None: """Return the current condition.""" - return self._weather_coordinator.data[ATTR_API_CONDITION] + return self.coordinator.data[ATTR_API_CONDITION] @property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" - return self._weather_coordinator.data[ATTR_API_CLOUDS] + return self.coordinator.data[ATTR_API_CLOUDS] @property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature.""" - return self._weather_coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] + return self.coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] @property def native_temperature(self) -> float | None: """Return the temperature.""" - return self._weather_coordinator.data[ATTR_API_TEMPERATURE] + return self.coordinator.data[ATTR_API_TEMPERATURE] @property def native_pressure(self) -> float | None: """Return the pressure.""" - return self._weather_coordinator.data[ATTR_API_PRESSURE] + return self.coordinator.data[ATTR_API_PRESSURE] @property def humidity(self) -> float | None: """Return the humidity.""" - return self._weather_coordinator.data[ATTR_API_HUMIDITY] + return self.coordinator.data[ATTR_API_HUMIDITY] @property def native_dew_point(self) -> float | None: """Return the dew point.""" - return self._weather_coordinator.data[ATTR_API_DEW_POINT] + return self.coordinator.data[ATTR_API_DEW_POINT] @property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed.""" - return self._weather_coordinator.data[ATTR_API_WIND_GUST] + return self.coordinator.data[ATTR_API_WIND_GUST] @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" - return self._weather_coordinator.data[ATTR_API_WIND_SPEED] + return self.coordinator.data[ATTR_API_WIND_SPEED] @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" - return self._weather_coordinator.data[ATTR_API_WIND_BEARING] + return self.coordinator.data[ATTR_API_WIND_BEARING] @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" - api_forecasts = self._weather_coordinator.data[ATTR_API_FORECAST] + api_forecasts = self.coordinator.data[ATTR_API_FORECAST] forecasts = [ { ha_key: forecast[api_key] @@ -188,17 +197,12 @@ class OpenWeatherMapWeather(WeatherEntity): ] return cast(list[Forecast], forecasts) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._weather_coordinator.last_update_success + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.forecast - async def async_added_to_hass(self) -> None: - """Connect to dispatcher listening for entity data notifications.""" - self.async_on_remove( - self._weather_coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self) -> None: - """Get the latest data from OWM and updates the states.""" - await self._weather_coordinator.async_request_refresh() + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return self.forecast diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 521c1f87ca2..56519c46fd9 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -1,8 +1,8 @@ """Weather data coordinator for the OpenWeatherMap (OWM) service.""" +import asyncio from datetime import timedelta import logging -import async_timeout from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from homeassistant.components.weather import ( @@ -46,7 +46,7 @@ from .const import ( ATTR_API_WIND_BEARING, ATTR_API_WIND_GUST, ATTR_API_WIND_SPEED, - CONDITION_CLASSES, + CONDITION_MAP, DOMAIN, FORECAST_MODE_DAILY, FORECAST_MODE_HOURLY, @@ -68,7 +68,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): self._owm_client = owm self._latitude = latitude self._longitude = longitude - self._forecast_mode = forecast_mode + self.forecast_mode = forecast_mode self._forecast_limit = None if forecast_mode == FORECAST_MODE_DAILY: self._forecast_limit = 15 @@ -80,7 +80,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update the data.""" data = {} - async with async_timeout.timeout(20): + async with asyncio.timeout(20): try: weather_response = await self._get_owm_weather() data = self._convert_weather_response(weather_response) @@ -90,7 +90,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _get_owm_weather(self): """Poll weather data from OWM.""" - if self._forecast_mode in ( + if self.forecast_mode in ( FORECAST_MODE_ONECALL_HOURLY, FORECAST_MODE_ONECALL_DAILY, ): @@ -106,17 +106,17 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_legacy_weather_and_forecast(self): """Get weather and forecast data from OWM.""" - interval = self._get_forecast_interval() + interval = self._get_legacy_forecast_interval() weather = self._owm_client.weather_at_coords(self._latitude, self._longitude) forecast = self._owm_client.forecast_at_coords( self._latitude, self._longitude, interval, self._forecast_limit ) return LegacyWeather(weather.weather, forecast.forecast.weathers) - def _get_forecast_interval(self): + def _get_legacy_forecast_interval(self): """Get the correct forecast interval depending on the forecast mode.""" interval = "daily" - if self._forecast_mode == FORECAST_MODE_HOURLY: + if self.forecast_mode == FORECAST_MODE_HOURLY: interval = "3h" return interval @@ -153,9 +153,9 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _get_forecast_from_weather_response(self, weather_response): """Extract the forecast data from the weather response.""" forecast_arg = "forecast" - if self._forecast_mode == FORECAST_MODE_ONECALL_HOURLY: + if self.forecast_mode == FORECAST_MODE_ONECALL_HOURLY: forecast_arg = "forecast_hourly" - elif self._forecast_mode == FORECAST_MODE_ONECALL_DAILY: + elif self.forecast_mode == FORECAST_MODE_ONECALL_DAILY: forecast_arg = "forecast_daily" return [ self._convert_forecast(x) for x in getattr(weather_response, forecast_arg) @@ -267,7 +267,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return ATTR_CONDITION_SUNNY return ATTR_CONDITION_CLEAR_NIGHT - return [k for k, v in CONDITION_CLASSES.items() if weather_code in v][0] + return CONDITION_MAP.get(weather_code) class LegacyWeather: diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index fdf007c3b68..d456fc536e5 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -5,16 +5,22 @@ from collections.abc import Mapping import logging from typing import Any -from opower import CannotConnect, InvalidAuth, Opower, get_supported_utility_names +from opower import ( + CannotConnect, + InvalidAuth, + Opower, + get_supported_utility_names, + select_utility, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_UTILITY, DOMAIN +from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -36,6 +42,7 @@ async def _validate_login( login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], + login_data.get(CONF_TOTP_SECRET), ) errors: dict[str, str] = {} try: @@ -55,6 +62,7 @@ class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new OpowerConfigFlow.""" self.reauth_entry: config_entries.ConfigEntry | None = None + self.utility_info: dict[str, Any] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -68,16 +76,56 @@ class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_USERNAME: user_input[CONF_USERNAME], } ) + if select_utility(user_input[CONF_UTILITY]).accepts_mfa(): + self.utility_info = user_input + return await self.async_step_mfa() + errors = await _validate_login(self.hass, user_input) if not errors: - return self.async_create_entry( - title=f"{user_input[CONF_UTILITY]} ({user_input[CONF_USERNAME]})", - data=user_input, - ) + return self._async_create_opower_entry(user_input) + return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + async def async_step_mfa( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle MFA step.""" + assert self.utility_info is not None + errors: dict[str, str] = {} + if user_input is not None: + data = {**self.utility_info, **user_input} + errors = await _validate_login(self.hass, data) + if not errors: + return self._async_create_opower_entry(data) + + if errors: + schema = { + vol.Required( + CONF_USERNAME, default=self.utility_info[CONF_USERNAME] + ): str, + vol.Required(CONF_PASSWORD): str, + } + else: + schema = {} + + schema[vol.Required(CONF_TOTP_SECRET)] = str + + return self.async_show_form( + step_id="mfa", + data_schema=vol.Schema(schema), + errors=errors, + ) + + @callback + def _async_create_opower_entry(self, data: dict[str, Any]) -> FlowResult: + """Create the config entry.""" + return self.async_create_entry( + title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", + data=data, + ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.reauth_entry = self.hass.config_entries.async_get_entry( @@ -100,13 +148,14 @@ class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") + schema = { + vol.Required(CONF_USERNAME): self.reauth_entry.data[CONF_USERNAME], + vol.Required(CONF_PASSWORD): str, + } + if select_utility(self.reauth_entry.data[CONF_UTILITY]).accepts_mfa(): + schema[vol.Optional(CONF_TOTP_SECRET)] = str return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema( - { - vol.Required(CONF_USERNAME): self.reauth_entry.data[CONF_USERNAME], - vol.Required(CONF_PASSWORD): str, - } - ), + data_schema=vol.Schema(schema), errors=errors, ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py index b996a214a05..c07d41bbdcf 100644 --- a/homeassistant/components/opower/const.py +++ b/homeassistant/components/opower/const.py @@ -3,3 +3,4 @@ DOMAIN = "opower" CONF_UTILITY = "utility" +CONF_TOTP_SECRET = "totp_secret" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 4e2b68df579..5ce35e949af 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -28,7 +28,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_UTILITY, DOMAIN +from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data[CONF_UTILITY], entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], + entry_data.get(CONF_TOTP_SECRET), ) async def _async_update_data( diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 2de5b268999..05e89ea96d4 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", - "requirements": ["opower==0.0.31"] + "loggers": ["opower"], + "requirements": ["opower==0.0.33"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index ad94d8cafb6..6be74deaebf 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 037983eb6ff..362e6cd7596 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -8,11 +8,18 @@ "password": "[%key:common::config_flow::data::password%]" } }, + "mfa": { + "description": "The TOTP secret below is not one of the 6 digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", + "data": { + "totp_secret": "TOTP Secret" + } + }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "totp_secret": "TOTP Secret" } } }, diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 76104c75164..c7cdaddf382 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -38,12 +38,15 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { ), OralBSensor.SECTOR: SensorEntityDescription( key=OralBSensor.SECTOR, + entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( key=OralBSensor.NUMBER_OF_SECTORS, + entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.SECTOR_TIMER: SensorEntityDescription( key=OralBSensor.SECTOR_TIMER, + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), OralBSensor.TOOTHBRUSH_STATE: SensorEntityDescription( @@ -52,6 +55,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { OralBSensor.PRESSURE: SensorEntityDescription(key=OralBSensor.PRESSURE), OralBSensor.MODE: SensorEntityDescription( key=OralBSensor.MODE, + entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.SIGNAL_STRENGTH: SensorEntityDescription( key=OralBSensor.SIGNAL_STRENGTH, @@ -66,6 +70,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), } @@ -111,7 +116,9 @@ async def async_setup_entry( OralBBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class OralBBluetoothSensorEntity( diff --git a/homeassistant/components/oru_opower/__init__.py b/homeassistant/components/oru_opower/__init__.py new file mode 100644 index 00000000000..c213a0f8883 --- /dev/null +++ b/homeassistant/components/oru_opower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Orange and Rockland Utilities (ORU) Opower.""" diff --git a/homeassistant/components/oru_opower/manifest.json b/homeassistant/components/oru_opower/manifest.json new file mode 100644 index 00000000000..bed159912be --- /dev/null +++ b/homeassistant/components/oru_opower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "oru_opower", + "name": "Orange and Rockland Utilities (ORU) Opower", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 8685282acec..0f4374d95bd 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -10,7 +10,7 @@ from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -36,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: otbrdata = OTBRData(entry.data["url"], api, entry.entry_id) try: + border_agent_id = await otbrdata.get_border_agent_id() dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( HomeAssistantError, @@ -43,9 +44,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: asyncio.TimeoutError, ) as err: raise ConfigEntryNotReady("Unable to connect") from err + if border_agent_id is None: + ir.async_create_issue( + hass, + DOMAIN, + f"get_get_border_agent_id_unsupported_{otbrdata.entry_id}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="get_get_border_agent_id_unsupported", + ) + return False if dataset_tlvs: await update_issues(hass, otbrdata, dataset_tlvs) - await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + await async_add_dataset( + hass, + DOMAIN, + dataset_tlvs.hex(), + preferred_border_agent_id=border_agent_id.hex(), + ) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index a8a5ae062f7..cf6aba33e80 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.3.0"] + "requirements": ["python-otbr-api==2.5.0"] } diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index 129cbec4468..838ebeb5b8c 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -16,6 +16,10 @@ } }, "issues": { + "get_get_border_agent_id_unsupported": { + "title": "The OTBR does not support border agent ID", + "description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR." + }, "insecure_thread_network": { "title": "Insecure Thread network settings detected", "description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network." diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 2d6217ea585..067282108f1 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine import dataclasses from functools import wraps +import logging from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api @@ -13,7 +14,7 @@ from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( MultiprotocolAddonManager, - get_addon_manager, + get_multiprotocol_addon_manager, is_multiprotocol_url, multi_pan_addon_using_device, ) @@ -27,6 +28,8 @@ from .const import DOMAIN _R = TypeVar("_R") _P = ParamSpec("_P") +_LOGGER = logging.getLogger(__name__) + INFO_URL_SKY_CONNECT = ( "https://skyconnect.home-assistant.io/multiprotocol-channel-missmatch" ) @@ -68,6 +71,25 @@ class OTBRData: api: python_otbr_api.OTBR entry_id: str + @_handle_otbr_error + async def factory_reset(self) -> None: + """Reset the router.""" + try: + await self.api.factory_reset() + except python_otbr_api.FactoryResetNotSupportedError: + _LOGGER.warning( + "OTBR does not support factory reset, attempting to delete dataset" + ) + await self.delete_active_dataset() + + @_handle_otbr_error + async def get_border_agent_id(self) -> bytes | None: + """Get the border agent ID or None if not supported by the router.""" + try: + return await self.api.get_border_agent_id() + except python_otbr_api.GetBorderAgentIdNotSupportedError: + return None + @_handle_otbr_error async def set_enabled(self, enabled: bool) -> None: """Enable or disable the router.""" @@ -124,8 +146,10 @@ async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: # The OTBR is not sharing the radio, no restriction return None - addon_manager: MultiprotocolAddonManager = await get_addon_manager(hass) - return addon_manager.async_get_channel() + multipan_manager: MultiprotocolAddonManager = await get_multiprotocol_addon_manager( + hass + ) + return multipan_manager.async_get_channel() async def _warn_on_channel_collision( diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 3b631057529..0693bc3a325 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -24,7 +24,6 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) - websocket_api.async_register_command(hass, websocket_get_extended_address) websocket_api.async_register_command(hass, websocket_set_channel) websocket_api.async_register_command(hass, websocket_set_network) @@ -47,18 +46,25 @@ async def websocket_info( data: OTBRData = hass.data[DOMAIN] try: + border_agent_id = await data.get_border_agent_id() dataset = await data.get_active_dataset() dataset_tlvs = await data.get_active_dataset_tlvs() + extended_address = await data.get_extended_address() except HomeAssistantError as exc: - connection.send_error(msg["id"], "get_dataset_failed", str(exc)) + connection.send_error(msg["id"], "otbr_info_failed", str(exc)) return + # The border agent ID is checked when the OTBR config entry is setup, + # we can assert it's not None + assert border_agent_id is not None connection.send_result( msg["id"], { - "url": data.url, "active_dataset_tlvs": dataset_tlvs.hex() if dataset_tlvs else None, + "border_agent_id": border_agent_id.hex(), "channel": dataset.channel if dataset else None, + "extended_address": extended_address.hex(), + "url": data.url, }, ) @@ -88,9 +94,9 @@ async def websocket_create_network( return try: - await data.delete_active_dataset() + await data.factory_reset() except HomeAssistantError as exc: - connection.send_error(msg["id"], "delete_active_dataset_failed", str(exc)) + connection.send_error(msg["id"], "factory_reset_failed", str(exc)) return try: @@ -187,32 +193,6 @@ async def websocket_set_network( connection.send_result(msg["id"]) -@websocket_api.websocket_command( - { - "type": "otbr/get_extended_address", - } -) -@websocket_api.require_admin -@websocket_api.async_response -async def websocket_get_extended_address( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict -) -> None: - """Get extended address (EUI-64).""" - if DOMAIN not in hass.data: - connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") - return - - data: OTBRData = hass.data[DOMAIN] - - try: - extended_address = await data.get_extended_address() - except HomeAssistantError as exc: - connection.send_error(msg["id"], "get_extended_address_failed", str(exc)) - return - - connection.send_result(msg["id"], {"extended_address": extended_address.hex()}) - - @websocket_api.websocket_command( { "type": "otbr/set_channel", diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index fa531410e33..3c0170e543f 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -6,7 +6,8 @@ from typing import cast from pyoverkiz.enums import OverkizAttribute, OverkizState from pyoverkiz.models import Device -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index d88996c7e02..8cf029adb54 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.9.0"], + "requirements": ["pyoverkiz==1.10.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index b5296d772df..f56643e8cd4 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 92d9aa118f0..3e2e868728d 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -1,11 +1,11 @@ """Support for OVO Energy.""" from __future__ import annotations -from datetime import datetime, timedelta +import asyncio +from datetime import timedelta import logging import aiohttp -import async_timeout from ovoenergy import OVODailyUsage from ovoenergy.ovoenergy import OVOEnergy @@ -13,13 +13,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) +from homeassistant.util import dt as dt_util from .const import CONF_ACCOUNT, DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> OVODailyUsage: """Fetch data from OVO Energy.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: authenticated = await client.authenticate( entry.data[CONF_USERNAME], @@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(exception) from exception if not authenticated: raise ConfigEntryAuthFailed("Not authenticated with OVO Energy") - return await client.get_daily_usage(datetime.utcnow().strftime("%Y-%m")) + return await client.get_daily_usage(dt_util.utcnow().strftime("%Y-%m")) coordinator = DataUpdateCoordinator[OVODailyUsage]( hass, @@ -99,6 +99,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class OVOEnergyEntity(CoordinatorEntity[DataUpdateCoordinator[OVODailyUsage]]): """Defines a base OVO Energy entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[OVODailyUsage], diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 2a4005e748f..b32a17f0323 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -43,7 +43,7 @@ class OVOEnergySensorEntityDescription(SensorEntityDescription): SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( OVOEnergySensorEntityDescription( key="last_electricity_reading", - name="OVO Last Electricity Reading", + translation_key="last_electricity_reading", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -51,7 +51,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key=KEY_LAST_ELECTRICITY_COST, - name="OVO Last Electricity Cost", + translation_key=KEY_LAST_ELECTRICITY_COST, device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, value=lambda usage: usage.electricity[-1].cost.amount @@ -60,14 +60,14 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key="last_electricity_start_time", - name="OVO Last Electricity Start Time", + translation_key="last_electricity_start_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.start), ), OVOEnergySensorEntityDescription( key="last_electricity_end_time", - name="OVO Last Electricity End Time", + translation_key="last_electricity_end_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.end), @@ -77,7 +77,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( OVOEnergySensorEntityDescription( key="last_gas_reading", - name="OVO Last Gas Reading", + translation_key="last_gas_reading", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -86,7 +86,7 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key=KEY_LAST_GAS_COST, - name="OVO Last Gas Cost", + translation_key=KEY_LAST_GAS_COST, device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:cash-multiple", @@ -96,14 +96,14 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key="last_gas_start_time", - name="OVO Last Gas Start Time", + translation_key="last_gas_start_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.start), ), OVOEnergySensorEntityDescription( key="last_gas_end_time", - name="OVO Last Gas End Time", + translation_key="last_gas_end_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.end), diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index 810602b1412..fda0c2996dc 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -24,5 +24,33 @@ "title": "Reauthentication" } } + }, + "entity": { + "sensor": { + "last_electricity_reading": { + "name": "Last electricity reading" + }, + "last_electricity_cost": { + "name": "Last electricity cost" + }, + "last_electricity_start_time": { + "name": "Last electricity start time" + }, + "last_electricity_end_time": { + "name": "Last electricity end time" + }, + "last_gas_reading": { + "name": "Last gas reading" + }, + "last_gas_cost": { + "name": "Last gas cost" + }, + "last_gas_start_time": { + "name": "Last gas start time" + }, + "last_gas_end_time": { + "name": "Last gas end time" + } + } } } diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 560493888d4..1b3d67ce7b4 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -278,7 +278,6 @@ class OwnTracksContext: func(**msg) self._pending_msg.clear() - # pylint: disable=method-hidden @callback def async_see(self, **data): """Send a see message to the device tracker.""" diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index ba0beb40cf8..e2053868cb9 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 21a878fa187..17fba104c7a 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -20,8 +20,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 5e2ed77233b..a159c47a7c9 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/panasonic_viera/remote.py b/homeassistant/components/panasonic_viera/remote.py index 717ee090612..b0512ededce 100644 --- a/homeassistant/components/panasonic_viera/remote.py +++ b/homeassistant/components/panasonic_viera/remote.py @@ -8,7 +8,7 @@ from homeassistant.components.remote import RemoteEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 43410895010..7b09f40c3f1 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -223,11 +223,9 @@ class PandoraMediaPlayer(MediaPlayerEntity): _LOGGER.warning("On unexpected station list page") self._pianobar.sendcontrol("m") # press enter self._pianobar.sendcontrol("m") # do it again b/c an 'i' got in - # pylint: disable=assignment-from-none response = self.update_playing_status() elif match_idx == 3: _LOGGER.debug("Received new playlist list") - # pylint: disable=assignment-from-none response = self.update_playing_status() else: response = self._pianobar.before.decode("utf-8") diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py index 8313bc4ba25..e33e5078288 100644 --- a/homeassistant/components/panel_iframe/__init__.py +++ b/homeassistant/components/panel_iframe/__init__.py @@ -20,7 +20,6 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: cv.schema_with_slug_keys( vol.Schema( { - # pylint: disable=no-value-for-parameter vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 5afc300bfa8..5be41f7c7e1 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE 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, @@ -42,7 +43,7 @@ PARALLEL_UPDATES: Final = 0 SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( PECOSensorEntityDescription( key="customers_out", - name="Customers Out", + translation_key="customers_out", value_fn=lambda data: int(data.outages.customers_out), attribute_fn=lambda data: {}, icon="mdi:power-plug-off", @@ -50,7 +51,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="percent_customers_out", - name="Percent Customers Out", + translation_key="percent_customers_out", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: int(data.outages.percent_customers_out), attribute_fn=lambda data: {}, @@ -59,7 +60,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="outage_count", - name="Outage Count", + translation_key="outage_count", value_fn=lambda data: int(data.outages.outage_count), attribute_fn=lambda data: {}, icon="mdi:power-plug-off", @@ -67,7 +68,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="customers_served", - name="Customers Served", + translation_key="customers_served", value_fn=lambda data: int(data.outages.customers_served), attribute_fn=lambda data: {}, icon="mdi:power-plug-off", @@ -75,7 +76,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="map_alert", - name="Map Alert", + translation_key="map_alert", value_fn=lambda data: str(data.alerts.alert_title), attribute_fn=lambda data: {ATTR_CONTENT: data.alerts.alert_content}, icon="mdi:alert", @@ -104,6 +105,8 @@ class PecoSensor( entity_description: PECOSensorEntityDescription + _attr_has_entity_name = True + def __init__( self, description: PECOSensorEntityDescription, @@ -112,8 +115,10 @@ class PecoSensor( ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = f"{county.capitalize()} {description.name}" self._attr_unique_id = f"{county}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, county)}, name=county.capitalize() + ) self.entity_description = description @property diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index 54208b12d93..059b2ba71a7 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -10,5 +10,24 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "customers_out": { + "name": "Customers out" + }, + "percent_customers_out": { + "name": "Percent customers out" + }, + "outage_count": { + "name": "Outage count" + }, + "customers_served": { + "name": "Customers served" + }, + "map_alert": { + "name": "Map alert" + } + } } } diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index a2767cb749b..e9e0e9d6aae 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -10,10 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - CONF_STATION, - DOMAIN, -) +from .const import CONF_STATION, DOMAIN from .coordinator import PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py index 8fab3ce36ae..9463aa48872 100644 --- a/homeassistant/components/pegel_online/coordinator.py +++ b/homeassistant/components/pegel_online/coordinator.py @@ -1,18 +1,17 @@ """DataUpdateCoordinator for pegel_online.""" import logging -from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station +from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station, StationMeasurements from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import MIN_TIME_BETWEEN_UPDATES -from .model import PegelOnlineData _LOGGER = logging.getLogger(__name__) -class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[PegelOnlineData]): +class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[StationMeasurements]): """DataUpdateCoordinator for the pegel_online integration.""" def __init__( @@ -28,13 +27,9 @@ class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[PegelOnlineData]): update_interval=MIN_TIME_BETWEEN_UPDATES, ) - async def _async_update_data(self) -> PegelOnlineData: + async def _async_update_data(self) -> StationMeasurements: """Fetch data from API endpoint.""" try: - water_level = await self.api.async_get_station_measurement( - self.station.uuid - ) + return await self.api.async_get_station_measurements(self.station.uuid) except CONNECT_ERRORS as err: raise UpdateFailed(f"Failed to communicate with API: {err}") from err - - return {"water_level": water_level} diff --git a/homeassistant/components/pegel_online/entity.py b/homeassistant/components/pegel_online/entity.py index c8a01623c7d..e9c4ebdb909 100644 --- a/homeassistant/components/pegel_online/entity.py +++ b/homeassistant/components/pegel_online/entity.py @@ -1,7 +1,7 @@ """The PEGELONLINE base entity.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index a51954496cd..9546017d4ff 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], - "requirements": ["aiopegelonline==0.0.5"] + "requirements": ["aiopegelonline==0.0.6"] } diff --git a/homeassistant/components/pegel_online/model.py b/homeassistant/components/pegel_online/model.py deleted file mode 100644 index c8dac75bcf2..00000000000 --- a/homeassistant/components/pegel_online/model.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Models for PEGELONLINE.""" - -from typing import TypedDict - -from aiopegelonline import CurrentMeasurement - - -class PegelOnlineData(TypedDict): - """TypedDict for PEGELONLINE Coordinator Data.""" - - water_level: CurrentMeasurement diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 14ec0c2d032..cf229f16d12 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -1,10 +1,12 @@ """PEGELONLINE sensor entities.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass +from aiopegelonline.models import CurrentMeasurement + from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -17,15 +19,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity -from .model import PegelOnlineData @dataclass class PegelOnlineRequiredKeysMixin: """Mixin for required keys.""" - fn_native_unit: Callable[[PegelOnlineData], str] - fn_native_value: Callable[[PegelOnlineData], float] + measurement_key: str @dataclass @@ -36,14 +36,71 @@ class PegelOnlineSensorEntityDescription( SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( + PegelOnlineSensorEntityDescription( + key="air_temperature", + translation_key="air_temperature", + measurement_key="air_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:thermometer-lines", + entity_registry_enabled_default=False, + ), + PegelOnlineSensorEntityDescription( + key="clearance_height", + translation_key="clearance_height", + 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( + key="ph_value", + measurement_key="ph_value", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PH, + entity_registry_enabled_default=False, + ), + PegelOnlineSensorEntityDescription( + key="water_speed", + translation_key="water_speed", + measurement_key="water_speed", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SPEED, + icon="mdi:waves-arrow-right", + entity_registry_enabled_default=False, + ), + PegelOnlineSensorEntityDescription( + key="water_flow", + translation_key="water_flow", + measurement_key="water_flow", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:waves", + entity_registry_enabled_default=False, + ), PegelOnlineSensorEntityDescription( key="water_level", translation_key="water_level", + measurement_key="water_level", state_class=SensorStateClass.MEASUREMENT, - fn_native_unit=lambda data: data["water_level"].uom, - fn_native_value=lambda data: data["water_level"].value, icon="mdi:waves-arrow-up", ), + PegelOnlineSensorEntityDescription( + key="water_temperature", + translation_key="water_temperature", + measurement_key="water_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:thermometer-water", + entity_registry_enabled_default=False, + ), ) @@ -51,9 +108,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the PEGELONLINE sensor.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: PegelOnlineDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( - [PegelOnlineSensor(coordinator, description) for description in SENSORS] + [ + PegelOnlineSensor(coordinator, description) + for description in SENSORS + if getattr(coordinator.data, description.measurement_key) is not None + ] ) @@ -71,9 +133,9 @@ class PegelOnlineSensor(PegelOnlineEntity, SensorEntity): super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{self.station.uuid}_{description.key}" - self._attr_native_unit_of_measurement = self.entity_description.fn_native_unit( - coordinator.data - ) + + if description.device_class != SensorDeviceClass.PH: + self._attr_native_unit_of_measurement = self.measurement.uom if self.station.latitude and self.station.longitude: self._attr_extra_state_attributes.update( @@ -83,7 +145,12 @@ class PegelOnlineSensor(PegelOnlineEntity, SensorEntity): } ) + @property + def measurement(self) -> CurrentMeasurement: + """Return the measurement data of the entity.""" + return getattr(self.coordinator.data, self.entity_description.measurement_key) + @property def native_value(self) -> float: """Return the state of the device.""" - return self.entity_description.fn_native_value(self.coordinator.data) + return self.measurement.value diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json index 930e349f9c3..e777f6169ba 100644 --- a/homeassistant/components/pegel_online/strings.json +++ b/homeassistant/components/pegel_online/strings.json @@ -26,8 +26,26 @@ }, "entity": { "sensor": { + "air_temperature": { + "name": "Air temperature" + }, + "clearance_height": { + "name": "Clearance height" + }, + "oxygen_level": { + "name": "Oxygen level" + }, + "water_speed": { + "name": "Water flow speed" + }, + "water_flow": { + "name": "Water volume flow" + }, "water_level": { "name": "Water level" + }, + "water_temperature": { + "name": "Water temperature" } } } diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 6f72f31ae8f..969c6c7b837 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -26,7 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 96cdd7ab105..c57b969ce9c 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -123,14 +123,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=MIN_TIME_BETWEEN_UPDATES, ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { DATA_KEY_API: api, DATA_KEY_COORDINATOR: coordinator, } - await coordinator.async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 06f4efd944e..00a9f534852 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -1,10 +1,10 @@ """Coordinator to fetch data from the Picnic API.""" +import asyncio from contextlib import suppress import copy from datetime import timedelta import logging -import async_timeout from python_picnic_api import PicnicAPI from python_picnic_api.session import PicnicAuthError @@ -44,7 +44,7 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): try: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. - async with async_timeout.timeout(10): + async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) # Update the auth token in the config entry if applicable diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 5e2e507e450..d4582afa3b2 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -14,8 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 ( diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index 23e94b5206d..4d9f1755145 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -51,7 +51,7 @@ class PicoProvider(Provider): with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: fname = tmpf.name - cmd = ["pico2wave", "--wave", fname, "-l", language, message] + cmd = ["pico2wave", "--wave", fname, "-l", language, "--", message] subprocess.call(cmd) data = None try: diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 786012d466c..6a150b3dc4c 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -8,7 +8,6 @@ import logging import re from typing import TYPE_CHECKING, Any -import async_timeout from icmplib import NameLookupError, async_ping import voluptuous as vol @@ -218,7 +217,7 @@ class PingDataSubProcess(PingData): close_fds=False, # required for posix_spawn ) try: - async with async_timeout.timeout(self._count + PING_TIMEOUT): + async with asyncio.timeout(self._count + PING_TIMEOUT): out_data, out_error = await pinger.communicate() if out_data: diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 8bdb7848bb1..755ff8d2ae7 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -2,6 +2,7 @@ from pyplaato.models.device import PlaatoDevice from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( @@ -56,10 +57,10 @@ class PlaatoEntity(entity.Entity): return f"{self._device_id}_{self._sensor_type}" @property - def device_info(self) -> entity.DeviceInfo: + def device_info(self) -> DeviceInfo: """Get device info.""" sw_version = self._sensor_data.firmware_version - return entity.DeviceInfo( + return DeviceInfo( identifiers={(DOMAIN, self._device_id)}, manufacturer="Plaato", model=self._device_type, diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 35073413037..58e0b78560b 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -5,8 +5,8 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 6585c011c2d..23f2895fd51 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -21,12 +21,11 @@ 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.device_registry import DeviceEntryType +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.network import is_internal_request diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 3b66fe0cf6d..a705d11cb41 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -10,8 +10,8 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 956dd7f36da..5da82ab4105 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -1,11 +1,11 @@ """Plugwise Binary Sensor component for Home Assistant.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from dataclasses import dataclass from typing import Any -from plugwise import SmileBinarySensors +from plugwise.constants import BinarySensorType from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -24,18 +24,10 @@ SEVERITIES = ["other", "info", "warning", "error"] @dataclass -class PlugwiseBinarySensorMixin: - """Mixin for required Plugwise binary sensor base description keys.""" - - value_fn: Callable[[SmileBinarySensors], bool] - - -@dataclass -class PlugwiseBinarySensorEntityDescription( - BinarySensorEntityDescription, PlugwiseBinarySensorMixin -): +class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Plugwise binary sensor entity.""" + key: BinarySensorType icon_off: str | None = None @@ -46,14 +38,12 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:hvac", icon_off="mdi:hvac-off", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["compressor_state"], ), PlugwiseBinarySensorEntityDescription( key="cooling_enabled", translation_key="cooling_enabled", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["cooling_enabled"], ), PlugwiseBinarySensorEntityDescription( key="dhw_state", @@ -61,7 +51,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:water-pump", icon_off="mdi:water-pump-off", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["dhw_state"], ), PlugwiseBinarySensorEntityDescription( key="flame_state", @@ -70,7 +59,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:fire", icon_off="mdi:fire-off", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["flame_state"], ), PlugwiseBinarySensorEntityDescription( key="heating_state", @@ -78,7 +66,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:radiator", icon_off="mdi:radiator-off", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["heating_state"], ), PlugwiseBinarySensorEntityDescription( key="cooling_state", @@ -86,7 +73,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:snowflake", icon_off="mdi:snowflake-off", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["cooling_state"], ), PlugwiseBinarySensorEntityDescription( key="slave_boiler_state", @@ -94,7 +80,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:fire", icon_off="mdi:circle-off-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["slave_boiler_state"], ), PlugwiseBinarySensorEntityDescription( key="plugwise_notification", @@ -102,7 +87,6 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( icon="mdi:mailbox-up-outline", icon_off="mdi:mailbox-outline", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data["plugwise_notification"], ), ) @@ -154,7 +138,7 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.entity_description.value_fn(self.device["binary_sensors"]) + return self.device["binary_sensors"][self.entity_description.key] @property def icon(self) -> str | None: diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index d0a65799807..610ffa34d7c 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -1,10 +1,10 @@ """Plugwise Climate component for Home Assistant.""" from __future__ import annotations -from collections.abc import Mapping from typing import Any from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ClimateEntity, @@ -130,13 +130,13 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if control_state == "off": return HVACAction.IDLE - hc_data = self.coordinator.data.devices[ - self.coordinator.data.gateway["heater_id"] - ] - if hc_data["binary_sensors"]["heating_state"]: - return HVACAction.HEATING - if hc_data["binary_sensors"].get("cooling_state"): - return HVACAction.COOLING + heater: str | None = self.coordinator.data.gateway["heater_id"] + if heater: + heater_data = self.coordinator.data.devices[heater] + if heater_data["binary_sensors"]["heating_state"]: + return HVACAction.HEATING + if heater_data["binary_sensors"].get("cooling_state"): + return HVACAction.COOLING return HVACAction.IDLE @@ -145,14 +145,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): """Return the current preset mode.""" return self.device.get("active_preset") - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes.""" - return { - "available_schemas": self.device["available_schedules"], - "selected_schema": self.device["selected_schedule"], - } - @plugwise_command async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -170,6 +162,9 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): ): raise ValueError("Invalid temperature change requested") + if mode := kwargs.get(ATTR_HVAC_MODE): + await self.async_set_hvac_mode(mode) + await self.coordinator.api.set_temperature(self.device["location"], data) @plugwise_command diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index afcd673ef7d..395ec4e6e63 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -1,9 +1,7 @@ """DataUpdateCoordinator for Plugwise.""" from datetime import timedelta -from typing import NamedTuple, cast -from plugwise import Smile -from plugwise.constants import DeviceData, GatewayData +from plugwise import PlugwiseData, Smile from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, @@ -23,13 +21,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_USERNAME, DOMAIN, LOGGER -class PlugwiseData(NamedTuple): - """Plugwise data stored in the DataUpdateCoordinator.""" - - gateway: GatewayData - devices: dict[str, DeviceData] - - class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): """Class to manage fetching Plugwise data from single endpoint.""" @@ -65,13 +56,13 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): """Connect to the Plugwise Smile.""" self._connected = await self.api.connect() self.api.get_all_devices() - self.name = self.api.smile_name self.update_interval = DEFAULT_SCAN_INTERVAL.get( str(self.api.smile_type), timedelta(seconds=60) ) async def _async_update_data(self) -> PlugwiseData: """Fetch data from Plugwise.""" + try: if not self._connected: await self._connect() @@ -87,7 +78,4 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): raise ConfigEntryError("Device with unsupported firmware") from err except ConnectionFailedError as err: raise UpdateFailed("Failed to connect to the Plugwise Smile") from err - return PlugwiseData( - gateway=cast(GatewayData, data[0]), - devices=cast(dict[str, DeviceData], data[1]), - ) + return data diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index e2ab5445f07..1c9149fad72 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -7,8 +7,8 @@ from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE, CONF_HOST from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, CONNECTION_ZIGBEE, + DeviceInfo, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -67,7 +67,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): """Return if entity is available.""" return ( self._dev_id in self.coordinator.data.devices - and ("available" not in self.device or self.device["available"]) + and ("available" not in self.device or self.device["available"] is True) and super().available ) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 4fdcd0a8bdd..ef0f01b38f7 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.31.1"], + "requirements": ["plugwise==0.31.9"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 25667ea16c6..5979480d90f 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass -from plugwise import ActuatorData, Smile +from plugwise import Smile +from plugwise.constants import NumberType from homeassistant.components.number import ( NumberDeviceClass, @@ -27,10 +28,6 @@ class PlugwiseEntityDescriptionMixin: """Mixin values for Plugwise entities.""" command: Callable[[Smile, str, float], Awaitable[None]] - native_max_value_fn: Callable[[ActuatorData], float] - native_min_value_fn: Callable[[ActuatorData], float] - native_step_fn: Callable[[ActuatorData], float] - native_value_fn: Callable[[ActuatorData], float] @dataclass @@ -39,6 +36,8 @@ class PlugwiseNumberEntityDescription( ): """Class describing Plugwise Number entities.""" + key: NumberType + NUMBER_TYPES = ( PlugwiseNumberEntityDescription( @@ -48,10 +47,14 @@ NUMBER_TYPES = ( device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - native_max_value_fn=lambda data: data["upper_bound"], - native_min_value_fn=lambda data: data["lower_bound"], - native_step_fn=lambda data: data["resolution"], - native_value_fn=lambda data: data["setpoint"], + ), + PlugwiseNumberEntityDescription( + key="max_dhw_temperature", + translation_key="max_dhw_temperature", + command=lambda api, number, value: api.set_number_setpoint(number, value), + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), ) @@ -70,7 +73,7 @@ async def async_setup_entry( entities: list[PlugwiseNumberEntity] = [] for device_id, device in coordinator.data.devices.items(): for description in NUMBER_TYPES: - if (actuator := device.get(description.key)) and "setpoint" in actuator: + if description.key in device: entities.append( PlugwiseNumberEntity(coordinator, device_id, description) ) @@ -91,30 +94,17 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): ) -> None: """Initiate Plugwise Number.""" super().__init__(coordinator, device_id) - self.actuator = self.device[description.key] self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX - - @property - def native_max_value(self) -> float: - """Return the setpoint max. value.""" - return self.entity_description.native_max_value_fn(self.actuator) - - @property - def native_min_value(self) -> float: - """Return the setpoint min. value.""" - return self.entity_description.native_min_value_fn(self.actuator) - - @property - def native_step(self) -> float: - """Return the setpoint step value.""" - return max(self.entity_description.native_step_fn(self.actuator), 1) + self._attr_native_max_value = self.device[description.key]["upper_bound"] + self._attr_native_min_value = self.device[description.key]["lower_bound"] + self._attr_native_step = max(self.device[description.key]["resolution"], 0.5) @property def native_value(self) -> float: """Return the present setpoint value.""" - return self.entity_description.native_value_fn(self.actuator) + return self.device[self.entity_description.key]["setpoint"] async def async_set_native_value(self, value: float) -> None: """Change to the new setpoint value.""" diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index b78fd689cb9..6646cce3369 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -3,9 +3,9 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any -from plugwise import DeviceData, Smile +from plugwise import Smile +from plugwise.constants import SelectOptionsType, SelectType from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -22,16 +22,17 @@ from .entity import PlugwiseEntity class PlugwiseSelectDescriptionMixin: """Mixin values for Plugwise Select entities.""" - command: Callable[[Smile, str, str], Awaitable[Any]] - value_fn: Callable[[DeviceData], str] - options_fn: Callable[[DeviceData], list[str]] + command: Callable[[Smile, str, str], Awaitable[None]] + options_key: SelectOptionsType @dataclass class PlugwiseSelectEntityDescription( SelectEntityDescription, PlugwiseSelectDescriptionMixin ): - """Class describing Plugwise Number entities.""" + """Class describing Plugwise Select entities.""" + + key: SelectType SELECT_TYPES = ( @@ -40,8 +41,7 @@ SELECT_TYPES = ( translation_key="select_schedule", icon="mdi:calendar-clock", command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON), - value_fn=lambda data: data["selected_schedule"], - options_fn=lambda data: data.get("available_schedules"), + options_key="available_schedules", ), PlugwiseSelectEntityDescription( key="select_regulation_mode", @@ -49,8 +49,7 @@ SELECT_TYPES = ( icon="mdi:hvac", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_regulation_mode(opt), - value_fn=lambda data: data["regulation_mode"], - options_fn=lambda data: data.get("regulation_modes"), + options_key="regulation_modes", ), PlugwiseSelectEntityDescription( key="select_dhw_mode", @@ -58,8 +57,7 @@ SELECT_TYPES = ( icon="mdi:shower", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_dhw_mode(opt), - value_fn=lambda data: data["dhw_mode"], - options_fn=lambda data: data.get("dhw_modes"), + options_key="dhw_modes", ), ) @@ -77,7 +75,7 @@ async def async_setup_entry( entities: list[PlugwiseSelectEntity] = [] for device_id, device in coordinator.data.devices.items(): for description in SELECT_TYPES: - if (options := description.options_fn(device)) and len(options) > 1: + if description.options_key in device: entities.append( PlugwiseSelectEntity(coordinator, device_id, description) ) @@ -100,16 +98,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): super().__init__(coordinator, device_id) self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" + self._attr_options = self.device[entity_description.options_key] @property def current_option(self) -> str: """Return the selected entity option to represent the entity state.""" - return self.entity_description.value_fn(self.device) - - @property - def options(self) -> list[str]: - """Return the selectable entity options.""" - return self.entity_description.options_fn(self.device) + return self.device[self.entity_description.key] async def async_select_option(self, option: str) -> None: """Change to the selected entity option.""" diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index d18226e5af9..0cc878178fe 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -1,6 +1,10 @@ """Plugwise Sensor component for Home Assistant.""" from __future__ import annotations +from dataclasses import dataclass + +from plugwise.constants import SensorType + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -27,8 +31,16 @@ from .const import DOMAIN from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass +class PlugwiseSensorEntityDescription(SensorEntityDescription): + """Describes Plugwise sensor entity.""" + + key: SensorType + + +SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( + PlugwiseSensorEntityDescription( key="setpoint", translation_key="setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -36,7 +48,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="setpoint_high", translation_key="cooling_setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -44,7 +56,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="setpoint_low", translation_key="heating_setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -52,14 +64,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="intended_boiler_temperature", translation_key="intended_boiler_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -67,7 +79,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="temperature_difference", translation_key="temperature_difference", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -75,14 +87,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="outdoor_temperature", translation_key="outdoor_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="outdoor_air_temperature", translation_key="outdoor_air_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -90,7 +102,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="water_temperature", translation_key="water_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -98,7 +110,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="return_temperature", translation_key="return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -106,14 +118,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed", translation_key="electricity_consumed", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced", translation_key="electricity_produced", native_unit_of_measurement=UnitOfPower.WATT, @@ -121,28 +133,28 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_interval", translation_key="electricity_consumed_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_peak_interval", translation_key="electricity_consumed_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_off_peak_interval", translation_key="electricity_consumed_off_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_interval", translation_key="electricity_produced_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -150,133 +162,133 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, entity_registry_enabled_default=False, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_peak_interval", translation_key="electricity_produced_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_off_peak_interval", translation_key="electricity_produced_off_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_point", translation_key="electricity_consumed_point", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_off_peak_point", translation_key="electricity_consumed_off_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_peak_point", translation_key="electricity_consumed_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_off_peak_cumulative", translation_key="electricity_consumed_off_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_consumed_peak_cumulative", translation_key="electricity_consumed_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_point", translation_key="electricity_produced_point", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_off_peak_point", translation_key="electricity_produced_off_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_peak_point", translation_key="electricity_produced_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_off_peak_cumulative", translation_key="electricity_produced_off_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_produced_peak_cumulative", translation_key="electricity_produced_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_phase_one_consumed", translation_key="electricity_phase_one_consumed", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_phase_two_consumed", translation_key="electricity_phase_two_consumed", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_phase_three_consumed", translation_key="electricity_phase_three_consumed", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_phase_one_produced", translation_key="electricity_phase_one_produced", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_phase_two_produced", translation_key="electricity_phase_two_produced", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="electricity_phase_three_produced", translation_key="electricity_phase_three_produced", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="voltage_phase_one", translation_key="voltage_phase_one", device_class=SensorDeviceClass.VOLTAGE, @@ -284,7 +296,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="voltage_phase_two", translation_key="voltage_phase_two", device_class=SensorDeviceClass.VOLTAGE, @@ -292,7 +304,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="voltage_phase_three", translation_key="voltage_phase_three", device_class=SensorDeviceClass.VOLTAGE, @@ -300,49 +312,49 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + 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, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="gas_consumed_cumulative", translation_key="gas_consumed_cumulative", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="net_electricity_point", translation_key="net_electricity_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="net_electricity_cumulative", translation_key="net_electricity_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="modulation_level", translation_key="modulation_level", icon="mdi:percent", @@ -350,7 +362,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="valve_position", translation_key="valve_position", icon="mdi:valve", @@ -358,7 +370,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="water_pressure", translation_key="water_pressure", native_unit_of_measurement=UnitOfPressure.BAR, @@ -366,13 +378,13 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="dhw_temperature", translation_key="dhw_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -380,7 +392,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + PlugwiseSensorEntityDescription( key="domestic_hot_water_setpoint", translation_key="domestic_hot_water_setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -388,14 +400,6 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( - key="maximum_boiler_temperature", - translation_key="maximum_boiler_temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, - ), ) @@ -409,11 +413,10 @@ async def async_setup_entry( entities: list[PlugwiseSensorEntity] = [] for device_id, device in coordinator.data.devices.items(): + if not (sensors := device.get("sensors")): + continue for description in SENSORS: - if ( - "sensors" not in device - or device["sensors"].get(description.key) is None - ): + if description.key not in sensors: continue entities.append( @@ -430,11 +433,13 @@ async def async_setup_entry( class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity): """Represent Plugwise Sensors.""" + entity_description: PlugwiseSensorEntityDescription + def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, device_id: str, - description: SensorEntityDescription, + description: PlugwiseSensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(coordinator, device_id) @@ -442,6 +447,6 @@ class PlugwiseSensorEntity(PlugwiseEntity, SensorEntity): self._attr_unique_id = f"{device_id}-{description.key}" @property - def native_value(self) -> int | float | None: + def native_value(self) -> int | float: """Return the value reported by the sensor.""" - return self.device["sensors"].get(self.entity_description.key) + return self.device["sensors"][self.entity_description.key] diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index e1b5b5c4053..5210f8a6dc0 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -76,6 +76,9 @@ "number": { "maximum_boiler_temperature": { "name": "Maximum boiler temperature setpoint" + }, + "max_dhw_temperature": { + "name": "Domestic hot water setpoint" } }, "select": { diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 4204ab5a4d9..8639826e37a 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -1,11 +1,10 @@ """Plugwise Switch component for HomeAssistant.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from typing import Any -from plugwise import SmileSwitches +from plugwise.constants import SwitchType from homeassistant.components.switch import ( SwitchDeviceClass, @@ -24,16 +23,11 @@ from .util import plugwise_command @dataclass -class PlugwiseSwitchBaseMixin: - """Mixin for required Plugwise switch description keys.""" - - value_fn: Callable[[SmileSwitches], bool] - - -@dataclass -class PlugwiseSwitchEntityDescription(SwitchEntityDescription, PlugwiseSwitchBaseMixin): +class PlugwiseSwitchEntityDescription(SwitchEntityDescription): """Describes Plugwise switch entity.""" + key: SwitchType + SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( PlugwiseSwitchEntityDescription( @@ -41,27 +35,24 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( translation_key="dhw_cm_switch", icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, - value_fn=lambda data: data["dhw_cm_switch"], ), PlugwiseSwitchEntityDescription( key="lock", translation_key="lock", icon="mdi:lock", entity_category=EntityCategory.CONFIG, - value_fn=lambda data: data["lock"], ), PlugwiseSwitchEntityDescription( key="relay", translation_key="relay", device_class=SwitchDeviceClass.SWITCH, - value_fn=lambda data: data["relay"], ), PlugwiseSwitchEntityDescription( key="cooling_ena_switch", + translation_key="cooling_ena_switch", name="Cooling", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.CONFIG, - value_fn=lambda data: data["cooling_ena_switch"], ), ) @@ -103,7 +94,7 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): @property def is_on(self) -> bool: """Return True if entity is on.""" - return self.entity_description.value_fn(self.device["switches"]) + return self.device["switches"][self.entity_description.key] @plugwise_command async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py index aa82d5662c6..241c14f29b9 100644 --- a/homeassistant/components/plum_lightpad/__init__.py +++ b/homeassistant/components/plum_lightpad/__init__.py @@ -12,9 +12,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, 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 @@ -47,12 +48,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = config[DOMAIN] - _LOGGER.info("Found Plum Lightpad configuration in config, importing") + _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 diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index ac0dd0c919c..2c1f7daa880 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 627736f605d..2030483d9cd 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -20,11 +20,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady 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, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index bfffb934407..c2a904ec2a1 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -16,8 +16,8 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinutPointClient diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index fad5b746252..201e397ba7d 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -3,7 +3,6 @@ import asyncio from collections import OrderedDict import logging -import async_timeout from pypoint import PointSession import voluptuous as vol @@ -94,7 +93,7 @@ class PointFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "follow_link" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): url = await self._get_authorization_url() except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index 312a3b4be58..56b7eaaac77 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -1,8 +1,8 @@ """The PoolSense integration.""" +import asyncio from datetime import timedelta import logging -import async_timeout from poolsense import PoolSense from poolsense.exceptions import PoolSenseError @@ -90,7 +90,7 @@ class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update data via library.""" data = {} - async with async_timeout.timeout(10): + async with asyncio.timeout(10): try: data = await self.poolsense.get_poolsense_data() except PoolSenseError as error: diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 8d66a12faad..33395f5fe6a 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -169,6 +169,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="Powerwall site", update_method=manager.async_update_data, update_interval=timedelta(seconds=UPDATE_INTERVAL), + always_update=False, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index 0bb089898d1..084ec0ea8a6 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -44,7 +44,7 @@ async def async_setup_entry( class PowerWallRunningSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall running sensor.""" - _attr_name = "Powerwall Status" + _attr_translation_key = "status" _attr_device_class = BinarySensorDeviceClass.POWER @property @@ -61,7 +61,7 @@ class PowerWallRunningSensor(PowerWallEntity, BinarySensorEntity): class PowerWallConnectedSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall connected sensor.""" - _attr_name = "Powerwall Connected to Tesla" + _attr_translation_key = "connected_to_tesla" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @property @@ -78,7 +78,7 @@ class PowerWallConnectedSensor(PowerWallEntity, BinarySensorEntity): class PowerWallGridServicesActiveSensor(PowerWallEntity, BinarySensorEntity): """Representation of a Powerwall grid services active sensor.""" - _attr_name = "Grid Services Active" + _attr_translation_key = "grid_services_active" _attr_device_class = BinarySensorDeviceClass.POWER @property @@ -95,7 +95,7 @@ class PowerWallGridServicesActiveSensor(PowerWallEntity, BinarySensorEntity): class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall grid status sensor.""" - _attr_name = "Grid Status" + _attr_translation_key = "grid_status" _attr_device_class = BinarySensorDeviceClass.POWER @property @@ -112,7 +112,6 @@ class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorEntity): class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall charging status sensor.""" - _attr_name = "Powerwall Charging" _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING @property diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index 1b42215483d..f0cfec2cbc5 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -1,6 +1,6 @@ """The Tesla Powerwall integration base entity.""" -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -20,6 +20,8 @@ from .models import PowerwallData, PowerwallRuntimeData class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): """Base class for powerwall entities.""" + _attr_has_entity_name = True + def __init__(self, powerwall_data: PowerwallRuntimeData) -> None: """Initialize the entity.""" base_info = powerwall_data[POWERWALL_BASE_INFO] diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index cf20e51314f..3f02c925f9d 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -69,7 +69,7 @@ def _get_meter_average_voltage(meter: Meter) -> float: POWERWALL_INSTANT_SENSORS = ( PowerwallSensorEntityDescription( key="instant_power", - name="Now", + translation_key="instant_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -77,7 +77,7 @@ POWERWALL_INSTANT_SENSORS = ( ), PowerwallSensorEntityDescription( key="instant_frequency", - name="Frequency Now", + translation_key="instant_frequency", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.FREQUENCY, native_unit_of_measurement=UnitOfFrequency.HERTZ, @@ -86,7 +86,7 @@ POWERWALL_INSTANT_SENSORS = ( ), PowerwallSensorEntityDescription( key="instant_current", - name="Average Current Now", + translation_key="instant_current", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -95,7 +95,7 @@ POWERWALL_INSTANT_SENSORS = ( ), PowerwallSensorEntityDescription( key="instant_voltage", - name="Average Voltage Now", + translation_key="instant_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -136,7 +136,7 @@ async def async_setup_entry( class PowerWallChargeSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall charge sensor.""" - _attr_name = "Powerwall Charge" + _attr_translation_key = "charge" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY @@ -167,10 +167,8 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): self.entity_description = description super().__init__(powerwall_data) self._meter = meter - self._attr_name = f"Powerwall {self._meter.value.title()} {description.name}" - self._attr_unique_id = ( - f"{self.base_unique_id}_{self._meter.value}_{description.key}" - ) + self._attr_translation_key = f"{meter.value}_{description.translation_key}" + self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{description.key}" @property def native_value(self) -> float: @@ -181,7 +179,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): """Representation of the Powerwall backup reserve setting.""" - _attr_name = "Powerwall Backup Reserve" + _attr_translation_key = "backup_reserve" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY @@ -215,7 +213,7 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): """Initialize the sensor.""" super().__init__(powerwall_data) self._meter = meter - self._attr_name = f"Powerwall {meter.value.title()} {meter_direction.title()}" + self._attr_translation_key = f"{meter.value}_{meter_direction}" self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{meter_direction}" @property diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 6306d52838e..dacf63a68dd 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -33,5 +33,142 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + }, + "connected_to_tesla": { + "name": "Connected to Tesla" + }, + "grid_status": { + "name": "Grid status" + }, + "grid_services_active": { + "name": "Grid services active" + } + }, + "sensor": { + "charge": { + "name": "Charge" + }, + "solar_instant_power": { + "name": "Solar power" + }, + "solar_instant_frequency": { + "name": "Solar frequency" + }, + "solar_instant_current": { + "name": "Solar current" + }, + "solar_instant_voltage": { + "name": "Solar voltage" + }, + "site_instant_power": { + "name": "Site power" + }, + "site_instant_frequency": { + "name": "Site frequency" + }, + "site_instant_current": { + "name": "Site current" + }, + "site_instant_voltage": { + "name": "Site voltage" + }, + "battery_instant_power": { + "name": "Battery power" + }, + "battery_instant_frequency": { + "name": "Battery frequency" + }, + "battery_instant_current": { + "name": "Battery current" + }, + "battery_instant_voltage": { + "name": "Battery voltage" + }, + "load_instant_power": { + "name": "Load power" + }, + "load_instant_frequency": { + "name": "Load frequency" + }, + "load_instant_current": { + "name": "Load current" + }, + "load_instant_voltage": { + "name": "Load voltage" + }, + "generator_instant_power": { + "name": "Generator power" + }, + "generator_instant_frequency": { + "name": "Generator frequency" + }, + "generator_instant_current": { + "name": "Generator current" + }, + "generator_instant_voltage": { + "name": "Generator voltage" + }, + "busway_instant_power": { + "name": "Busway power" + }, + "busway_instant_frequency": { + "name": "Busway frequency" + }, + "busway_instant_current": { + "name": "Busway current" + }, + "busway_instant_voltage": { + "name": "Busway voltage" + }, + "backup_reserve": { + "name": "Backup reserve" + }, + "solar_import": { + "name": "Solar import" + }, + "solar_export": { + "name": "Solar export" + }, + "site_import": { + "name": "Site import" + }, + "site_export": { + "name": "Site export" + }, + "battery_import": { + "name": "Battery import" + }, + "battery_export": { + "name": "Battery export" + }, + "load_import": { + "name": "Load import" + }, + "load_export": { + "name": "Load export" + }, + "generator_import": { + "name": "Generator import" + }, + "generator_export": { + "name": "Generator export" + }, + "busway_import": { + "name": "Busway import" + }, + "busway_export": { + "name": "Busway export" + } + }, + "switch": { + "off_grid_operation": { + "name": "Off-grid operation" + } + } } } diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index 48db62df97a..8516890d633 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -34,8 +34,7 @@ async def async_setup_entry( class PowerwallOffGridEnabledEntity(PowerWallEntity, SwitchEntity): """Representation of a Switch entity for Powerwall Off-grid operation.""" - _attr_name = "Off-Grid operation" - _attr_has_entity_name = True + _attr_translation_key = "off_grid_operation" _attr_entity_category = EntityCategory.CONFIG _attr_device_class = SwitchDeviceClass.SWITCH diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index b2019389fe3..e2d1025cc64 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -1,8 +1,8 @@ """Control binary sensor instances.""" +import asyncio from datetime import timedelta import logging -import async_timeout from ProgettiHWSW.input import Input from homeassistant.components.binary_sensor import BinarySensorEntity @@ -32,7 +32,7 @@ async def async_setup_entry( async def async_update_data(): """Fetch data from API endpoint of board.""" - async with async_timeout.timeout(5): + async with asyncio.timeout(5): return await board_api.get_inputs() coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index dc7f838bcbc..77cfb6ba4d1 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -1,9 +1,9 @@ """Control switches.""" +import asyncio from datetime import timedelta import logging from typing import Any -import async_timeout from ProgettiHWSW.relay import Relay from homeassistant.components.switch import SwitchEntity @@ -33,7 +33,7 @@ async def async_setup_entry( async def async_update_data(): """Fetch data from API endpoint of board.""" - async with async_timeout.timeout(5): + async with asyncio.timeout(5): return await board_api.get_switches() coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 2bc9fbb5324..1818f308239 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components.climate import ( from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, @@ -44,6 +45,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter _LOGGER = logging.getLogger(__name__) @@ -52,6 +54,7 @@ API_ENDPOINT = "/api/prometheus" DOMAIN = "prometheus" CONF_FILTER = "filter" +CONF_REQUIRES_AUTH = "requires_auth" CONF_PROM_NAMESPACE = "namespace" CONF_COMPONENT_CONFIG = "component_config" CONF_COMPONENT_CONFIG_GLOB = "component_config_glob" @@ -70,6 +73,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, vol.Optional(CONF_PROM_NAMESPACE, default=DEFAULT_NAMESPACE): cv.string, + vol.Optional(CONF_REQUIRES_AUTH, default=True): cv.boolean, vol.Optional(CONF_DEFAULT_METRIC): cv.string, vol.Optional(CONF_OVERRIDE_METRIC): cv.string, vol.Optional(CONF_COMPONENT_CONFIG, default={}): vol.Schema( @@ -90,7 +94,9 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Prometheus component.""" - hass.http.register_view(PrometheusView(prometheus_client)) + hass.http.register_view( + PrometheusView(prometheus_client, config[DOMAIN][CONF_REQUIRES_AUTH]) + ) conf = config[DOMAIN] entity_filter = conf[CONF_FILTER] @@ -114,10 +120,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: default_metric, ) - hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed) + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) hass.bus.listen( EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated ) + + for state in hass.states.all(): + if entity_filter(state.entity_id): + metrics.handle_state(state) + return True @@ -143,6 +154,7 @@ class PrometheusMetrics: self._sensor_metric_handlers = [ self._sensor_override_component_metric, self._sensor_override_metric, + self._sensor_timestamp_metric, self._sensor_attribute_metric, self._sensor_default_metric, self._sensor_fallback_metric, @@ -155,16 +167,13 @@ class PrometheusMetrics: self._metrics = {} self._climate_units = climate_units - def handle_state_changed(self, event): - """Listen for new messages on the bus, and add them to Prometheus.""" + def handle_state_changed_event(self, event): + """Handle new messages from the bus.""" if (state := event.data.get("new_state")) is None: return - entity_id = state.entity_id - _LOGGER.debug("Handling state update for %s", entity_id) - domain, _ = hacore.split_entity_id(entity_id) - if not self._filter(state.entity_id): + _LOGGER.debug("Filtered out entity %s", state.entity_id) return if (old_state := event.data.get("old_state")) is not None and ( @@ -172,6 +181,14 @@ class PrometheusMetrics: ) != state.attributes.get(ATTR_FRIENDLY_NAME): self._remove_labelsets(old_state.entity_id, old_friendly_name) + self.handle_state(state) + + def handle_state(self, state): + """Add/update a state in Prometheus.""" + entity_id = state.entity_id + _LOGGER.debug("Handling state update for %s", entity_id) + domain, _ = hacore.split_entity_id(entity_id) + ignored_states = (STATE_UNAVAILABLE, STATE_UNKNOWN) handler = f"_handle_{domain}" @@ -288,7 +305,10 @@ class PrometheusMetrics: def state_as_number(state): """Return a state casted to a float.""" try: - value = state_helper.state_as_number(state) + if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: + value = as_timestamp(state.state) + else: + value = state_helper.state_as_number(state) except ValueError: _LOGGER.debug("Could not convert %s to float", state) value = 0 @@ -572,6 +592,14 @@ class PrometheusMetrics: return f"sensor_{metric}_{unit}" return None + @staticmethod + def _sensor_timestamp_metric(state, unit): + """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): """Get metric from override in configuration.""" if self._override_metric: @@ -650,8 +678,9 @@ class PrometheusView(HomeAssistantView): url = API_ENDPOINT name = "api:prometheus" - def __init__(self, prometheus_cli): + def __init__(self, prometheus_cli, requires_auth: bool) -> None: """Initialize Prometheus view.""" + self.requires_auth = requires_auth self.prometheus_cli = prometheus_cli async def get(self, request): diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json index 8ec332c1daf..cb8defb2ed5 100644 --- a/homeassistant/components/prometheus/manifest.json +++ b/homeassistant/components/prometheus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prometheus", "iot_class": "assumed_state", "loggers": ["prometheus_client"], - "requirements": ["prometheus-client==0.7.1"] + "requirements": ["prometheus-client==0.17.1"] } diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index b05a5f245ff..77cdb5e11a2 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -47,6 +47,8 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_HOME ) + _attr_has_entity_name = True + _attr_name = None _installation: Installation def __init__(self, contract: str, auth: Auth) -> None: @@ -57,14 +59,13 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._auth = auth self._attr_code_arm_required = False - self._attr_name = f"contract {self.contract}" - self._attr_unique_id = self.contract + self._attr_unique_id = contract self._attr_device_info = DeviceInfo( - name="Prosegur Alarm", + name=f"Contract {contract}", manufacturer="Prosegur", model="smart", - identifiers={(DOMAIN, self.contract)}, + identifiers={(DOMAIN, contract)}, configuration_url="https://smart.prosegur.com", ) diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 9041a6526fb..c711ca2eac6 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -10,7 +10,7 @@ from pyprosegur.installation import Camera as InstallationCamera, Installation from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -50,6 +50,8 @@ async def async_setup_entry( class ProsegurCamera(Camera): """Representation of a Smart Prosegur Camera.""" + _attr_has_entity_name = True + def __init__( self, installation: Installation, camera: InstallationCamera, auth: Auth ) -> None: @@ -59,14 +61,14 @@ class ProsegurCamera(Camera): self._installation = installation self._camera = camera self._auth = auth + self._attr_unique_id = f"{installation.contract} {camera.id}" self._attr_name = camera.description - self._attr_unique_id = f"{self._installation.contract} {camera.id}" self._attr_device_info = DeviceInfo( - name=self._camera.description, + name=f"Contract {installation.contract}", manufacturer="Prosegur", - model="smart camera", - identifiers={(DOMAIN, self._installation.contract)}, + model="smart", + identifiers={(DOMAIN, installation.contract)}, configuration_url="https://smart.prosegur.com", ) diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index 02d4f61f4e4..d0b35aaf4b9 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -5,7 +5,6 @@ import asyncio from http import HTTPStatus import logging -import async_timeout import voluptuous as vol from homeassistant.components.notify import ( @@ -64,7 +63,7 @@ class ProwlNotificationService(BaseNotificationService): session = async_get_clientsession(self._hass) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): response = await session.post(url, data=payload) result = await response.text() diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 70853623f0e..e81901dad52 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -2,19 +2,19 @@ from __future__ import annotations from abc import ABC, abstractmethod +import asyncio from datetime import timedelta import logging from time import monotonic from typing import Generic, TypeVar -import async_timeout from pyprusalink import InvalidAuth, JobInfo, PrinterInfo, PrusaLink, PrusaLinkError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -77,7 +77,7 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC): async def _async_update_data(self) -> T: """Update the data.""" try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): data = await self._fetch_data() except InvalidAuth: raise UpdateFailed("Invalid authentication") from None diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index cef2bdf2f6e..b1faad6e3ea 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -6,7 +6,6 @@ import logging from typing import Any from aiohttp import ClientError -import async_timeout from awesomeversion import AwesomeVersion, AwesomeVersionException from pyprusalink import InvalidAuth, PrusaLink import voluptuous as vol @@ -39,7 +38,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): version = await api.get_version() except (asyncio.TimeoutError, ClientError) as err: diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 42bc15cf0ca..f14ef6ce2aa 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.json import JsonObjectType diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 9f67665d66c..4ab77fa7893 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index f5c4090dc87..6b998f6879e 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -9,7 +9,7 @@ from aiopurpleair.models.sensors import SensorModel from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_SHOW_ON_MAP, DOMAIN diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 77bcf63e17e..a4fec1c3d4d 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -7,7 +7,6 @@ from datetime import timedelta import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components import webhook @@ -74,7 +73,7 @@ async def async_setup_platform( async def handle_webhook(hass, webhook_id, request): """Handle incoming webhook POST with image files.""" try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): data = dict(await request.post()) except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: _LOGGER.error("Could not get information from POST <%s>", error) diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 84d2998e992..2f2a1d066f3 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -5,9 +5,8 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .api import PushBulletNotificationProvider diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index b681678b098..bcf869d3bba 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 64e6e19086f..8db978135f6 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["aiopvpc"], "quality_scale": "platinum", - "requirements": ["aiopvpc==4.1.0"] + "requirements": ["aiopvpc==4.2.2"] } diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 56b77dec401..3368b24b3ff 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -12,10 +12,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CURRENCY_EURO, UnitOfEnergy +from homeassistant.const import CURRENCY_EURO, UnitOfEnergy from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.typing import StateType @@ -32,6 +31,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:currency-eur", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + name="PVPC", ), ) _PRICE_SENSOR_ATTRIBUTES_MAP = { @@ -119,34 +119,31 @@ async def async_setup_entry( ) -> None: """Set up the electricity price sensor from config_entry.""" coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - name = entry.data[CONF_NAME] - async_add_entities( - [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id, name)] - ) + async_add_entities([ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)]) class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], SensorEntity): """Class to hold the prices of electricity as a sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: ElecPricesDataUpdateCoordinator, description: SensorEntityDescription, unique_id: str | None, - name: str, ) -> None: """Initialize ESIOS sensor.""" super().__init__(coordinator) self.entity_description = description self._attr_attribution = coordinator.api.attribution self._attr_unique_id = unique_id - self._attr_name = name self._attr_device_info = DeviceInfo( configuration_url="https://api.esios.ree.es", entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.entry_id)}, manufacturer="REE", - name="ESIOS API", + name="ESIOS", ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/qingping/binary_sensor.py b/homeassistant/components/qingping/binary_sensor.py index 3a40e1baa09..99bcf83ec1a 100644 --- a/homeassistant/components/qingping/binary_sensor.py +++ b/homeassistant/components/qingping/binary_sensor.py @@ -87,7 +87,9 @@ async def async_setup_entry( QingpingBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, BinarySensorEntityDescription) + ) class QingpingBluetoothSensorEntity( diff --git a/homeassistant/components/qingping/sensor.py b/homeassistant/components/qingping/sensor.py index 4ee1db90c6f..bc99ed80ff3 100644 --- a/homeassistant/components/qingping/sensor.py +++ b/homeassistant/components/qingping/sensor.py @@ -155,7 +155,9 @@ async def async_setup_entry( QingpingBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class QingpingBluetoothSensorEntity( diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index febd4b61ebb..4bf410c7f87 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index 27387447b51..5c3fbe13aff 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -23,6 +23,7 @@ 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 homeassistant.helpers.typing import UNDEFINED from .const import ATTR_MESSAGE, DOMAIN, QSW_COORD_DATA from .coordinator import QswDataCoordinator @@ -48,7 +49,6 @@ BINARY_SENSOR_TYPES: Final[tuple[QswBinarySensorEntityDescription, ...]] = ( device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, key=QSD_FIRMWARE_CONDITION, - name="Anomaly", subkey=QSD_ANOMALY, ), ) @@ -140,8 +140,10 @@ class QswBinarySensor(QswSensorEntity, BinarySensorEntity): ) -> None: """Initialize.""" super().__init__(coordinator, entry, type_id) - - self._attr_name = f"{self.product} {description.name}" + if description.name == UNDEFINED: + self._attr_has_entity_name = True + else: + self._attr_name = f"{self.product} {description.name}" self._attr_unique_id = ( f"{entry.unique_id}_{description.key}" f"{description.sep_key}{description.subkey}" diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index 1c89504e810..acd8d3bd1ef 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -39,7 +39,6 @@ BUTTON_TYPES: Final[tuple[QswButtonDescription, ...]] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, key=QSW_REBOOT, - name="Reboot", press_action=lambda qsw: qsw.reboot(), ), ) @@ -58,6 +57,8 @@ async def async_setup_entry( class QswButton(QswDataEntity, ButtonEntity): """Define a QNAP QSW button.""" + _attr_has_entity_name = True + entity_description: QswButtonDescription def __init__( @@ -68,7 +69,6 @@ class QswButton(QswDataEntity, ButtonEntity): ) -> None: """Initialize.""" super().__init__(coordinator, entry) - self._attr_name = f"{self.product} {description.name}" self._attr_unique_id = f"{entry.unique_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py index eb4e60bf9bd..6451b525004 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -1,13 +1,13 @@ """The QNAP QSW coordinator.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any from aioqsw.exceptions import QswError from aioqsw.localapi import QnapQswApi -import async_timeout from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -36,7 +36,7 @@ class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - async with async_timeout.timeout(QSW_TIMEOUT_SEC): + async with asyncio.timeout(QSW_TIMEOUT_SEC): try: await self.qsw.update() except QswError as error: @@ -60,7 +60,7 @@ class QswFirmwareCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update firmware data via library.""" - async with async_timeout.timeout(QSW_TIMEOUT_SEC): + async with asyncio.timeout(QSW_TIMEOUT_SEC): try: await self.qsw.check_firmware() except QswError as error: diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index 38e45457462..4bbfba423e9 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -18,8 +18,8 @@ from aioqsw.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import MANUFACTURER @@ -120,6 +120,8 @@ class QswSensorEntity(QswDataEntity): class QswFirmwareEntity(CoordinatorEntity[QswFirmwareCoordinator]): """Define a QNAP QSW firmware entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: QswFirmwareCoordinator, diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index 17825110490..28e1ba7b8e4 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", "iot_class": "local_polling", "loggers": ["aioqsw"], - "requirements": ["aioqsw==0.3.2"] + "requirements": ["aioqsw==0.3.4"] } diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index 676fe586c37..0c287c66073 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -43,6 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED from .const import ATTR_MAX, DOMAIN, QSW_COORD_DATA, RPM from .coordinator import QswDataCoordinator @@ -60,57 +61,57 @@ class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription): SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = ( QswSensorEntityDescription( + translation_key="fan_1_speed", icon="mdi:fan-speed-1", key=QSD_SYSTEM_SENSOR, - name="Fan 1 Speed", native_unit_of_measurement=RPM, state_class=SensorStateClass.MEASUREMENT, subkey=QSD_FAN1_SPEED, ), QswSensorEntityDescription( + translation_key="fan_2_speed", icon="mdi:fan-speed-2", key=QSD_SYSTEM_SENSOR, - name="Fan 2 Speed", native_unit_of_measurement=RPM, state_class=SensorStateClass.MEASUREMENT, subkey=QSD_FAN2_SPEED, ), QswSensorEntityDescription( + translation_key="ports", attributes={ ATTR_MAX: [QSD_SYSTEM_BOARD, QSD_PORT_NUM], }, entity_registry_enabled_default=False, icon="mdi:ethernet", key=QSD_PORTS_STATUS, - name="Ports", state_class=SensorStateClass.MEASUREMENT, subkey=QSD_LINK, ), QswSensorEntityDescription( entity_registry_enabled_default=False, + translation_key="rx", device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:download-network", key=QSD_PORTS_STATISTICS, - name="RX", native_unit_of_measurement=UnitOfInformation.BYTES, state_class=SensorStateClass.TOTAL_INCREASING, subkey=QSD_RX_OCTETS, ), QswSensorEntityDescription( entity_registry_enabled_default=False, + translation_key="rx_errors", icon="mdi:close-network", key=QSD_PORTS_STATISTICS, entity_category=EntityCategory.DIAGNOSTIC, - name="RX Errors", state_class=SensorStateClass.TOTAL_INCREASING, subkey=QSD_RX_ERRORS, ), QswSensorEntityDescription( entity_registry_enabled_default=False, + translation_key="rx_speed", device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download-network", key=QSD_PORTS_STATISTICS, - name="RX Speed", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, subkey=QSD_RX_SPEED, @@ -121,36 +122,35 @@ SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = ( }, device_class=SensorDeviceClass.TEMPERATURE, key=QSD_SYSTEM_SENSOR, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, subkey=QSD_TEMP, ), QswSensorEntityDescription( entity_registry_enabled_default=False, + translation_key="tx", device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:upload-network", key=QSD_PORTS_STATISTICS, - name="TX", native_unit_of_measurement=UnitOfInformation.BYTES, state_class=SensorStateClass.TOTAL_INCREASING, subkey=QSD_TX_OCTETS, ), QswSensorEntityDescription( entity_registry_enabled_default=False, + translation_key="tx_speed", device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload-network", key=QSD_PORTS_STATISTICS, - name="TX Speed", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, subkey=QSD_TX_SPEED, ), QswSensorEntityDescription( + translation_key="uptime", icon="mdi:timer-outline", key=QSD_SYSTEM_TIME, entity_category=EntityCategory.DIAGNOSTIC, - name="Uptime", native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.TOTAL_INCREASING, subkey=QSD_UPTIME, @@ -363,7 +363,10 @@ class QswSensor(QswSensorEntity, SensorEntity): """Initialize.""" super().__init__(coordinator, entry, type_id) - self._attr_name = f"{self.product} {description.name}" + if description.name == UNDEFINED: + self._attr_has_entity_name = True + else: + self._attr_name = f"{self.product} {description.name}" self._attr_unique_id = ( f"{entry.unique_id}_{description.key}" f"{description.sep_key}{description.subkey}" diff --git a/homeassistant/components/qnap_qsw/strings.json b/homeassistant/components/qnap_qsw/strings.json index ba0cb28ba77..c8cd5ffb861 100644 --- a/homeassistant/components/qnap_qsw/strings.json +++ b/homeassistant/components/qnap_qsw/strings.json @@ -23,5 +23,36 @@ } } } + }, + "entity": { + "sensor": { + "fan_1_speed": { + "name": "Fan 1 speed" + }, + "fan_2_speed": { + "name": "Fan 2 speed" + }, + "ports": { + "name": "Ports" + }, + "rx": { + "name": "RX" + }, + "rx_errors": { + "name": "RX errors" + }, + "rx_speed": { + "name": "RX speed" + }, + "tx": { + "name": "TX" + }, + "tx_speed": { + "name": "TX speed" + }, + "uptime": { + "name": "Uptime" + } + } } } diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index 38a963818d4..5ea6e80f4bb 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -7,8 +7,6 @@ from aioqsw.const import ( QSD_DESCRIPTION, QSD_FIRMWARE_CHECK, QSD_FIRMWARE_INFO, - QSD_PRODUCT, - QSD_SYSTEM_BOARD, QSD_VERSION, ) @@ -32,7 +30,6 @@ UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = ( device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.CONFIG, key=QSW_UPDATE, - name="Firmware Update", ), ) @@ -63,9 +60,6 @@ class QswUpdate(QswFirmwareEntity, UpdateEntity): ) -> None: """Initialize.""" super().__init__(coordinator, entry) - self._attr_name = ( - f"{self.get_device_value(QSD_SYSTEM_BOARD, QSD_PRODUCT)} {description.name}" - ) self._attr_unique_id = f"{entry.unique_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index f1c515d37f7..029b1bac6e3 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -43,7 +43,6 @@ async def async_setup_entry( """Set up the Rachio binary sensors.""" entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) async_add_entities(entities) - _LOGGER.debug("%d Rachio binary sensor(s) added", len(entities)) def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: @@ -58,6 +57,8 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): """Represent a binary sensor that reflects a Rachio state.""" + _attr_has_entity_name = True + def __init__(self, controller): """Set up a new Rachio controller binary sensor.""" super().__init__(controller) @@ -86,26 +87,13 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): """Represent a binary sensor that reflects if the controller is online.""" - @property - def name(self) -> str: - """Return the name of this sensor including the controller name.""" - return self._controller.name + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @property def unique_id(self) -> str: """Return a unique id for this entity.""" return f"{self._controller.controller_id}-online" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this device, from BinarySensorDeviceClass.""" - return BinarySensorDeviceClass.CONNECTIVITY - - @property - def icon(self) -> str: - """Return the name of an icon for this sensor.""" - return "mdi:wifi-strength-4" if self.is_on else "mdi:wifi-strength-off-outline" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" @@ -132,26 +120,14 @@ class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): class RachioRainSensor(RachioControllerBinarySensor): """Represent a binary sensor that reflects the status of the rain sensor.""" - @property - def name(self) -> str: - """Return the name of this sensor including the controller name.""" - return f"{self._controller.name} rain sensor" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + _attr_translation_key = "rain" @property def unique_id(self) -> str: """Return a unique id for this entity.""" return f"{self._controller.controller_id}-rain_sensor" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this device.""" - return BinarySensorDeviceClass.MOISTURE - - @property - def icon(self) -> str: - """Return the icon for this sensor.""" - return "mdi:water" if self.is_on else "mdi:water-off" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index a109c4b99f7..fc0dc1f1aae 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -1,7 +1,8 @@ """Adapter to wrap the rachiopy api for home assistant.""" from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DEFAULT_NAME, DOMAIN from .device import RachioIro diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index 2132cab8682..560c300db17 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -27,6 +27,21 @@ } } }, + "entity": { + "binary_sensor": { + "rain": { + "name": "Rain" + } + }, + "switch": { + "standby": { + "name": "Standby" + }, + "rain_delay": { + "name": "Rain delay" + } + } + }, "services": { "set_zone_moisture_percent": { "name": "Set zone moisture percent", diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index c04a1a09f81..0557a2bdb19 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -109,7 +109,6 @@ async def async_setup_entry( has_flex_sched = True async_add_entities(entities) - _LOGGER.debug("%d Rachio switch(es) added", len(entities)) def start_multiple(service: ServiceCall) -> None: """Service to start multiple zones in sequence.""" @@ -173,7 +172,6 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent entities.append(RachioZone(person, controller, zone, current_schedule)) for sched in schedules + flex_schedules: entities.append(RachioSchedule(person, controller, sched, current_schedule)) - _LOGGER.debug("Added %s", entities) return entities @@ -185,11 +183,6 @@ class RachioSwitch(RachioDevice, SwitchEntity): super().__init__(controller) self._state = None - @property - def name(self) -> str: - """Get a name for this switch.""" - return f"Switch on {self._controller.name}" - @property def is_on(self) -> bool: """Return whether the switch is currently on.""" @@ -213,21 +206,15 @@ class RachioSwitch(RachioDevice, SwitchEntity): class RachioStandbySwitch(RachioSwitch): """Representation of a standby status/button.""" - @property - def name(self) -> str: - """Return the name of the standby switch.""" - return f"{self._controller.name} in standby mode" + _attr_has_entity_name = True + _attr_translation_key = "standby" + _attr_icon = "mdi:power" @property def unique_id(self) -> str: """Return a unique id by combining controller id and purpose.""" return f"{self._controller.controller_id}-standby" - @property - def icon(self) -> str: - """Return an icon for the standby switch.""" - return "mdi:power" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" @@ -263,26 +250,20 @@ class RachioStandbySwitch(RachioSwitch): class RachioRainDelay(RachioSwitch): """Representation of a rain delay status/switch.""" + _attr_has_entity_name = True + _attr_translation_key = "rain_delay" + _attr_icon = "mdi:camera-timer" + def __init__(self, controller): """Set up a Rachio rain delay switch.""" self._cancel_update = None super().__init__(controller) - @property - def name(self) -> str: - """Return the name of the switch.""" - return f"{self._controller.name} rain delay" - @property def unique_id(self) -> str: """Return a unique id by combining controller id and purpose.""" return f"{self._controller.controller_id}-delay" - @property - def icon(self) -> str: - """Return an icon for rain delay.""" - return "mdi:camera-timer" - @callback def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 3584d5242b6..c7f31a999e7 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -16,8 +16,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 367e302d56f..803b6de44a4 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any, Generic from aiopyarr import Diskspace, RootFolder, SystemStatus @@ -88,7 +88,7 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data, _: data.startTime.replace(tzinfo=timezone.utc), + value_fn=lambda data, _: data.startTime.replace(tzinfo=UTC), ), } diff --git a/homeassistant/components/radiotherm/entity.py b/homeassistant/components/radiotherm/entity.py index 7eb14548ada..384c97cac2c 100644 --- a/homeassistant/components/radiotherm/entity.py +++ b/homeassistant/components/radiotherm/entity.py @@ -4,7 +4,7 @@ from abc import abstractmethod from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import RadioThermUpdateCoordinator diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 0409d0ff564..a784e4623d6 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -6,7 +6,6 @@ import asyncio import logging from typing import Any -import async_timeout from pyrainbird.async_client import ( AsyncRainbirdClient, AsyncRainbirdController, @@ -106,7 +105,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) ) try: - async with async_timeout.timeout(TIMEOUT_SECONDS): + async with asyncio.timeout(TIMEOUT_SECONDS): return await controller.get_serial_number() except asyncio.TimeoutError as err: raise ConfigFlowError( diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index d76ac78f7e9..d81b942d669 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -2,17 +2,21 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass import datetime import logging from typing import TypeVar -import async_timeout -from pyrainbird.async_client import AsyncRainbirdController, RainbirdApiException +from pyrainbird.async_client import ( + AsyncRainbirdController, + RainbirdApiException, + RainbirdDeviceBusyException, +) from pyrainbird.data import ModelAndVersion from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS @@ -82,10 +86,12 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): async def _async_update_data(self) -> RainbirdDeviceState: """Fetch data from Rain Bird device.""" try: - async with async_timeout.timeout(TIMEOUT_SECONDS): + async with asyncio.timeout(TIMEOUT_SECONDS): return await self._fetch_data() + except RainbirdDeviceBusyException as err: + raise UpdateFailed("Rain Bird device is busy") from err except RainbirdApiException as err: - raise UpdateFailed(f"Error communicating with Device: {err}") from err + raise UpdateFailed("Rain Bird device failure") from err async def _fetch_data(self) -> RainbirdDeviceState: """Fetch data from the Rain Bird device. diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index febb960d652..de049f921dd 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -3,10 +3,13 @@ from __future__ import annotations import logging +from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException + from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -58,4 +61,11 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self.coordinator.controller.set_rain_delay(value) + try: + await self.coordinator.controller.set_rain_delay(value) + except RainbirdDeviceBusyException as err: + raise HomeAssistantError( + "Rain Bird device is busy; Wait and try again" + ) from err + except RainbirdApiException as err: + raise HomeAssistantError("Rain Bird device failure") from err diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 3e2a3115e29..ac42e00c676 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -3,13 +3,15 @@ from __future__ import annotations import logging +from pyrainbird.exceptions import RainbirdApiException, RainbirdDeviceBusyException import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -86,15 +88,30 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) async def async_turn_on(self, **kwargs): """Turn the switch on.""" - await self.coordinator.controller.irrigate_zone( - int(self._zone), - int(kwargs.get(ATTR_DURATION, self._duration_minutes)), - ) + try: + await self.coordinator.controller.irrigate_zone( + int(self._zone), + int(kwargs.get(ATTR_DURATION, self._duration_minutes)), + ) + except RainbirdDeviceBusyException as err: + raise HomeAssistantError( + "Rain Bird device is busy; Wait and try again" + ) from err + except RainbirdApiException as err: + raise HomeAssistantError("Rain Bird device failure") from err + await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs): """Turn the switch off.""" - await self.coordinator.controller.stop_irrigation() + try: + await self.coordinator.controller.stop_irrigation() + except RainbirdDeviceBusyException as err: + raise HomeAssistantError( + "Rain Bird device is busy; Wait and try again" + ) from err + except RainbirdApiException as err: + raise HomeAssistantError("Rain Bird device failure") from err await self.coordinator.async_request_refresh() @property diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index c7ef596bb61..f050e92f783 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -1,12 +1,12 @@ """Rainforest data.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging import aioeagle import aiohttp -import async_timeout from eagle100 import Eagle as Eagle100Reader from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout @@ -50,7 +50,7 @@ async def async_get_type(hass, cloud_id, install_code, host): ) try: - async with async_timeout.timeout(30): + async with asyncio.timeout(30): meters = await hub.get_device_list() except aioeagle.BadAuth as err: raise InvalidAuth from err @@ -150,7 +150,7 @@ class EagleDataCoordinator(DataUpdateCoordinator): else: is_connected = eagle200_meter.is_connected - async with async_timeout.timeout(30): + async with asyncio.timeout(30): data = await eagle200_meter.get_device_query() if self.eagle200_meter is None: diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index a7fd27a051f..113cfceb7d6 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 2b3f642dfe4..c29154a941c 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -31,7 +31,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed from homeassistant.util.dt import as_timestamp, utcnow from homeassistant.util.network import is_ip_address @@ -219,10 +219,11 @@ async def async_setup_entry( # noqa: C901 """Set up RainMachine as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) client = Client(session=websession) + ip_address = entry.data[CONF_IP_ADDRESS] try: await client.load_local( - entry.data[CONF_IP_ADDRESS], + ip_address, entry.data[CONF_PASSWORD], port=entry.data[CONF_PORT], use_ssl=entry.data.get(CONF_SSL, DEFAULT_SSL), @@ -238,6 +239,7 @@ async def async_setup_entry( # noqa: C901 if not entry.unique_id or is_ip_address(entry.unique_id): # If the config entry doesn't already have a unique ID, set one: entry_updates["unique_id"] = controller.mac + if CONF_DEFAULT_ZONE_RUN_TIME in entry.data: # If a zone run time exists in the config entry's data, pop it and move it to # options: @@ -252,6 +254,17 @@ async def async_setup_entry( # noqa: C901 if entry_updates: hass.config_entries.async_update_entry(entry, **entry_updates) + if entry.unique_id and controller.mac != entry.unique_id: + # If the mac address of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {ip_address}; expected {entry.unique_id}, " + f"found {controller.mac}" + ) + async def async_update(api_category: str) -> dict: """Update the appropriate API data based on a category.""" data: dict = {} diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index fc48ebce4eb..ac2b86754e5 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -91,11 +91,6 @@ "hot_days_extra_watering": { "name": "Extra water on hot days" } - }, - "update": { - "firmware": { - "name": "Firmware" - } } }, "services": { diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index 372319ba9a0..8d5690b5320 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -44,7 +44,6 @@ UPDATE_STATE_MAP = { UPDATE_DESCRIPTION = RainMachineEntityDescription( key="update", - translation_key="firmware", api_category=DATA_MACHINE_FIRMWARE_UPDATE_STATUS, ) diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index 61ef1be500a..64917b6d721 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -106,6 +106,7 @@ class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): name=name, update_interval=update_interval, update_method=update_method, + always_update=False, ) self._rebooting = False diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 9d895f35eb7..16a93485b36 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index 2c324ca7093..f330ac16b8e 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -14,8 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index 21cf574d548..076067312eb 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -1,7 +1,7 @@ """The ReCollect Waste integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, timedelta from typing import Any from aiorecollect.client import Client, PickupEvent @@ -31,7 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_get_pickup_events() -> list[PickupEvent]: """Get the next pickup.""" try: - return await client.async_get_pickup_events() + # Retrieve today through to 35 days in the future, to get + # coverage across a full two months boundary so that no + # upcoming pickups are missed. The api.recollect.net base API + # call returns only the current month when no dates are passed. + # This ensures that data about when the next pickup is will be + # returned when the next pickup is the first day of the next month. + # Ex: Today is August 31st, tomorrow is a pickup on September 1st. + today = date.today() + return await client.async_get_pickup_events( + start_date=today, + end_date=today + timedelta(days=35), + ) except RecollectError as err: raise UpdateFailed( f"Error while requesting data from ReCollect: {err}" diff --git a/homeassistant/components/recollect_waste/entity.py b/homeassistant/components/recollect_waste/entity.py index 41781b10355..5ccd65cc55a 100644 --- a/homeassistant/components/recollect_waste/entity.py +++ b/homeassistant/components/recollect_waste/entity.py @@ -2,8 +2,7 @@ from aiorecollect.client import PickupEvent from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index 8a24dcbf92b..5b7a141bd70 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -37,8 +37,6 @@ def _find_duplicates( literal_column("1").label("is_duplicate"), ) .group_by(table.metadata_id, table.start) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable .having(func.count() > 1) .subquery() ) @@ -195,8 +193,6 @@ def _find_statistics_meta_duplicates(session: Session) -> list[int]: literal_column("1").label("is_duplicate"), ) .group_by(StatisticsMeta.statistic_id) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable .having(func.count() > 1) .subquery() ) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index fc7683db901..724a9589680 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -3,9 +3,7 @@ from enum import StrEnum from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES -from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-import - JSON_DUMP, -) +from homeassistant.helpers.json import JSON_DUMP # noqa: F401 DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index d4a026cfefc..bbaff24ff77 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -13,7 +13,6 @@ import threading import time from typing import Any, TypeVar, cast -import async_timeout import psutil_home_assistant as ha_psutil from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.engine import Engine @@ -693,6 +692,10 @@ class Recorder(threading.Thread): """Run the recorder thread.""" try: self._run() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception( + "Recorder._run threw unexpected exception, recorder shutting down" + ) finally: # Ensure shutdown happens cleanly if # anything goes wrong in the run loop @@ -1306,7 +1309,7 @@ class Recorder(threading.Thread): task = DatabaseLockTask(database_locked, threading.Event(), False) self.queue_task(task) try: - async with async_timeout.timeout(DB_LOCK_TIMEOUT): + async with asyncio.timeout(DB_LOCK_TIMEOUT): await database_locked.wait() except asyncio.TimeoutError as err: task.database_unlock.set() diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index c99aadb8caa..508874c54e5 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -63,7 +63,6 @@ from .models import ( # SQLAlchemy Schema -# pylint: disable=invalid-name class Base(DeclarativeBase): """Base class for tables.""" diff --git a/homeassistant/components/recorder/executor.py b/homeassistant/components/recorder/executor.py index 6eea2f651c3..3f677e72fdf 100644 --- a/homeassistant/components/recorder/executor.py +++ b/homeassistant/components/recorder/executor.py @@ -39,7 +39,6 @@ class DBInterruptibleThreadPoolExecutor(InterruptibleThreadPoolExecutor): # When the executor gets lost, the weakref callback will wake up # the worker threads. - # pylint: disable=invalid-name def weakref_cb( # type: ignore[no-untyped-def] _: Any, q=self._work_queue, diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 24d22704a89..bf76c7264d5 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -187,8 +187,6 @@ class Filters: if self._included_domains or self._included_entity_globs: return or_( i_entities, - # https://github.com/sqlalchemy/sqlalchemy/issues/9190 - # pylint: disable-next=invalid-unary-operand-type (~e_entities & (i_entity_globs | (~e_entity_globs & i_domains))), ).self_group() diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 64ce1aa7d55..191c74ac0d4 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -565,8 +565,6 @@ def _get_states_for_entities_stmt( most_recent_states_for_entities_by_date := ( select( States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated_ts).label("max_last_updated"), ) .filter( @@ -590,8 +588,6 @@ def _get_states_for_entities_stmt( ( most_recent_states_for_entities_by_date := select( States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated).label("max_last_updated"), ) .filter( diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 393bcfa3676..68c357c0ed4 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -432,8 +432,6 @@ def _get_last_state_changes_single_stmt(metadata_id: int) -> Select: lastest_state_for_metadata_id := ( select( States.metadata_id.label("max_metadata_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated_ts).label("max_last_updated"), ) .filter(States.metadata_id == metadata_id) @@ -537,8 +535,6 @@ def _get_start_time_state_for_entities_stmt( most_recent_states_for_entities_by_date := ( select( States.metadata_id.label("max_metadata_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(States.last_updated_ts).label("max_last_updated"), ) .filter( diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 6f919ee50da..63b19cdb3bf 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.15", - "fnv-hash-fast==0.4.0", + "fnv-hash-fast==0.4.1", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 8fe1d0482e9..f07e91ddaea 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -524,7 +524,7 @@ def _update_states_table_with_foreign_key_options( return states_key_constraints = Base.metadata.tables[TABLE_STATES].foreign_key_constraints - old_states_table = Table( # noqa: F841 pylint: disable=unused-variable + old_states_table = Table( # noqa: F841 TABLE_STATES, MetaData(), *(alter["old_fk"] for alter in alters) # type: ignore[arg-type] ) @@ -553,9 +553,7 @@ def _drop_foreign_key_constraints( drops.append(ForeignKeyConstraint((), (), name=foreign_key["name"])) # Bind the ForeignKeyConstraints to the table - old_table = Table( # noqa: F841 pylint: disable=unused-variable - table, MetaData(), *drops - ) + old_table = Table(table, MetaData(), *drops) # noqa: F841 for drop in drops: with session_scope(session=session_maker()) as session: @@ -772,8 +770,6 @@ def _apply_update( # noqa: C901 with session_scope(session=session_maker()) as session: if session.query(Statistics.id).count() and ( last_run_string := session.query( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(StatisticsRuns.start) ).scalar() ): diff --git a/homeassistant/components/recorder/models/context.py b/homeassistant/components/recorder/models/context.py index f722c519833..f25e4d4412f 100644 --- a/homeassistant/components/recorder/models/context.py +++ b/homeassistant/components/recorder/models/context.py @@ -18,7 +18,7 @@ def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: try: return ulid_to_bytes(ulid) except ValueError as ex: - _LOGGER.error("Error converting ulid %s to bytes: %s", ulid, ex, exc_info=True) + _LOGGER.exception("Error converting ulid %s to bytes: %s", ulid, ex) return None @@ -29,9 +29,7 @@ def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: try: return bytes_to_ulid(_bytes) except ValueError as ex: - _LOGGER.error( - "Error converting bytes %s to ulid: %s", _bytes, ex, exc_info=True - ) + _LOGGER.exception("Error converting bytes %s to ulid: %s", _bytes, ex) return None diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 078a982d5ad..40e4afd18a7 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -7,8 +7,6 @@ from typing import overload import homeassistant.util.dt as dt_util -# pylint: disable=invalid-name - _LOGGER = logging.getLogger(__name__) DB_TIMEZONE = "+00:00" diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 49f66fdcd68..71a996f0381 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -76,8 +76,6 @@ def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElemen def _state_attrs_exist(attr: int | None) -> Select: """Check if a state attributes id exists in the states table.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return select(func.min(States.attributes_id)).where(States.attributes_id == attr) @@ -315,8 +313,6 @@ def data_ids_exist_in_events_with_fast_in_distinct( def _event_data_id_exist(data_id: int | None) -> Select: """Check if a event data id exists in the events table.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return select(func.min(Events.data_id)).where(Events.data_id == data_id) @@ -659,8 +655,6 @@ def find_statistics_runs_to_purge( def find_latest_statistics_runs_run_id() -> StatementLambdaElement: """Find the latest statistics_runs run_id.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return lambda_stmt(lambda: select(func.max(StatisticsRuns.run_id))) @@ -696,8 +690,6 @@ def find_legacy_detached_states_and_attributes_to_purge( def find_legacy_row() -> StatementLambdaElement: """Check if there are still states in the table with an event_id.""" - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable return lambda_stmt(lambda: select(func.max(States.event_id))) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9bbf35bb40a..005859b865b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -103,11 +103,7 @@ QUERY_STATISTICS_SHORT_TERM = ( QUERY_STATISTICS_SUMMARY_MEAN = ( StatisticsShortTerm.metadata_id, func.avg(StatisticsShortTerm.mean), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.min(StatisticsShortTerm.min), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(StatisticsShortTerm.max), ) @@ -417,8 +413,6 @@ def compile_missing_statistics(instance: Recorder) -> bool: exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: # Find the newest statistics run, if any - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) @@ -1078,17 +1072,11 @@ def _get_max_mean_min_statistic_in_sub_period( # Calculate max, mean, min columns = select() if "max" in types: - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable columns = columns.add_columns(func.max(table.max)) if "mean" in types: columns = columns.add_columns(func.avg(table.mean)) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable columns = columns.add_columns(func.count(table.mean)) if "min" in types: - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable columns = columns.add_columns(func.min(table.min)) stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( columns, start_time, end_time, table, metadata_id @@ -1831,8 +1819,6 @@ def _latest_short_term_statistics_stmt( most_recent_statistic_row := ( select( StatisticsShortTerm.metadata_id, - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(StatisticsShortTerm.start_ts).label("start_max"), ) .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) @@ -1895,8 +1881,6 @@ def _generate_statistics_at_time_stmt( ( most_recent_statistic_ids := ( select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable func.max(table.start_ts).label("max_start_ts"), table.metadata_id.label("max_metadata_id"), ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index f3de9824a16..d438cbede9f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -132,7 +132,7 @@ def session_scope( need_rollback = True session.commit() except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Error executing query: %s", err, exc_info=True) + _LOGGER.exception("Error executing query: %s", err) if need_rollback: session.rollback() if not exception_filter or not exception_filter(err): @@ -426,7 +426,7 @@ def _datetime_or_none(value: str) -> datetime | None: def build_mysqldb_conv() -> dict: """Build a MySQLDB conv dict that uses cisco8601 to parse datetimes.""" # Late imports since we only call this if they are using mysqldb - # pylint: disable=import-outside-toplevel,import-error + # pylint: disable=import-outside-toplevel from MySQLdb.constants import FIELD_TYPE from MySQLdb.converters import conversions diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 5f2670fb170..e5470259aa4 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.1.13"] + "requirements": ["renault-api==0.2.0"] } diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index b7a9b40e2c9..49819dd919f 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_SW_VERSION, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -57,8 +58,15 @@ class RenaultHub: self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() - device_registry = dr.async_get(self._hass) if vehicles.vehicleLinks: + if any( + vehicle_link.vehicleDetails is None + for vehicle_link in vehicles.vehicleLinks + ): + raise ConfigEntryNotReady( + "Failed to retrieve vehicle details from Renault servers" + ) + device_registry = dr.async_get(self._hass) await asyncio.gather( *( self.async_initialise_vehicle( diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 30e251dd30b..6dd0dc2611e 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -15,7 +15,7 @@ from renault_api.renault_vehicle import RenaultVehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 050c5a930f6..92deb3438de 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -190,6 +190,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( key="charging_remaining_time", coordinator="battery", data_key="chargingRemainingTime", + device_class=SensorDeviceClass.DURATION, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 211f7c88e40..86dfdc1f18b 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -1,12 +1,12 @@ """The Renson integration.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import timedelta import logging from typing import Any -import async_timeout from renson_endura_delta.renson import RensonVentilation from homeassistant.config_entries import ConfigEntry @@ -20,6 +20,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.SENSOR, ] @@ -84,5 +85,5 @@ class RensonCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Fetch data from API endpoint.""" - async with async_timeout.timeout(30): + async with asyncio.timeout(30): return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py new file mode 100644 index 00000000000..cad8b92c0c3 --- /dev/null +++ b/homeassistant/components/renson/binary_sensor.py @@ -0,0 +1,136 @@ +"""Binary sensors for renson.""" +from __future__ import annotations + +from dataclasses import dataclass + +from renson_endura_delta.field_enum import ( + AIR_QUALITY_CONTROL_FIELD, + BREEZE_ENABLE_FIELD, + BREEZE_MET_FIELD, + CO2_CONTROL_FIELD, + FROST_PROTECTION_FIELD, + HUMIDITY_CONTROL_FIELD, + PREHEATER_FIELD, + DataType, + FieldEnum, +) +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + + +@dataclass +class RensonBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + field: FieldEnum + + +@dataclass +class RensonBinarySensorEntityDescription( + BinarySensorEntityDescription, RensonBinarySensorEntityDescriptionMixin +): + """Description of binary sensor.""" + + +BINARY_SENSORS: tuple[RensonBinarySensorEntityDescription, ...] = ( + RensonBinarySensorEntityDescription( + translation_key="frost_protection_active", + key="FROST_PROTECTION_FIELD", + field=FROST_PROTECTION_FIELD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RensonBinarySensorEntityDescription( + key="BREEZE_ENABLE_FIELD", + translation_key="breeze", + field=BREEZE_ENABLE_FIELD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RensonBinarySensorEntityDescription( + key="BREEZE_MET_FIELD", + translation_key="breeze_conditions_met", + field=BREEZE_MET_FIELD, + ), + RensonBinarySensorEntityDescription( + key="HUMIDITY_CONTROL_FIELD", + translation_key="humidity_control", + field=HUMIDITY_CONTROL_FIELD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RensonBinarySensorEntityDescription( + key="AIR_QUALITY_CONTROL_FIELD", + translation_key="air_quality_control", + field=AIR_QUALITY_CONTROL_FIELD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RensonBinarySensorEntityDescription( + key="CO2_CONTROL_FIELD", + translation_key="co2_control", + field=CO2_CONTROL_FIELD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RensonBinarySensorEntityDescription( + key="PREHEATER_FIELD", + translation_key="preheater", + field=PREHEATER_FIELD, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Call the Renson integration to setup.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities( + RensonBinarySensor(description, api, coordinator) + for description in BINARY_SENSORS + ) + + +class RensonBinarySensor(RensonEntity, BinarySensorEntity): + """Get sensor data from the Renson API and store it in the state of the class.""" + + _attr_has_entity_name = True + + def __init__( + self, + description: RensonBinarySensorEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, api, coordinator) + + self.field = description.field + 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.field.name) + + self._attr_is_on = self.api.parse_value(value, DataType.BOOLEAN) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py index 526077d2d7f..245b55d6611 100644 --- a/homeassistant/components/renson/entity.py +++ b/homeassistant/components/renson/entity.py @@ -9,7 +9,7 @@ from renson_endura_delta.field_enum import ( ) from renson_endura_delta.renson import RensonVentilation -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RensonCoordinator diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index c8a355a0f7c..661ab82f373 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -266,6 +266,8 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( class RensonSensor(RensonEntity, SensorEntity): """Get a sensor data from the Renson API and store it in the state of the class.""" + _attr_has_entity_name = True + def __init__( self, description: RensonSensorEntityDescription, diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 06636c9d503..20db9e788b8 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -13,6 +13,29 @@ } }, "entity": { + "binary_sensor": { + "frost_protection_active": { + "name": "Frost protection active" + }, + "breeze": { + "name": "Breeze" + }, + "breeze_conditions_met": { + "name": "Breeze conditions met" + }, + "humidity_control": { + "name": "Humidity control" + }, + "air_quality_control": { + "name": "Air quality control" + }, + "co2_control": { + "name": "CO2 control" + }, + "preheater": { + "name": "Preheater" + } + }, "sensor": { "co2_quality_category": { "name": "CO2 quality category", diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 88eec9780a1..5cfb2ceecb7 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -8,7 +8,6 @@ from datetime import timedelta import logging from typing import Literal -import async_timeout from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError @@ -78,13 +77,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" - async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): + async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: await host.update_states() except ReolinkError as err: raise UpdateFailed(str(err)) from err - async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): + async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() async def async_check_firmware_update() -> str | Literal[False]: @@ -92,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not host.api.supported(None, "update"): return False - async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): + async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: return await host.api.check_new_firmware() except (ReolinkError, asyncio.exceptions.CancelledError) as err: diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 996f2c6b3ab..49e964e2b3f 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -49,26 +49,25 @@ class ReolinkBinarySensorEntityDescription( BINARY_SENSORS = ( ReolinkBinarySensorEntityDescription( key="motion", - name="Motion", device_class=BinarySensorDeviceClass.MOTION, value=lambda api, ch: api.motion_detected(ch), ), ReolinkBinarySensorEntityDescription( key=FACE_DETECTION_TYPE, - name="Face", + translation_key="face", icon="mdi:face-recognition", value=lambda api, ch: api.ai_detected(ch, FACE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, FACE_DETECTION_TYPE), ), ReolinkBinarySensorEntityDescription( key=PERSON_DETECTION_TYPE, - name="Person", + translation_key="person", value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE), ), ReolinkBinarySensorEntityDescription( key=VEHICLE_DETECTION_TYPE, - name="Vehicle", + translation_key="vehicle", icon="mdi:car", icon_off="mdi:car-off", value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE), @@ -76,7 +75,7 @@ BINARY_SENSORS = ( ), ReolinkBinarySensorEntityDescription( key=PET_DETECTION_TYPE, - name="Pet", + translation_key="pet", icon="mdi:dog-side", icon_off="mdi:dog-side-off", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), @@ -84,7 +83,7 @@ BINARY_SENSORS = ( ), ReolinkBinarySensorEntityDescription( key="visitor", - name="Visitor", + translation_key="visitor", icon="mdi:bell-ring-outline", icon_off="mdi:doorbell", value=lambda api, ch: api.visitor_detected(ch), @@ -130,7 +129,11 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt self.entity_description = entity_description if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: - self._attr_name = f"{entity_description.name} lens {self._channel}" + if entity_description.translation_key is not None: + key = entity_description.translation_key + else: + key = entity_description.key + self._attr_translation_key = f"{key}_lens_{self._channel}" self._attr_unique_id = ( f"{self._host.unique_id}_{self._channel}_{entity_description.key}" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 7a6e2486c71..f1797527914 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -36,6 +36,7 @@ class ReolinkButtonEntityDescription( """A class that describes button entities for a camera channel.""" supported: Callable[[Host, int], bool] = lambda api, ch: True + enabled_default: Callable[[Host, int], bool] | None = None @dataclass @@ -57,42 +58,60 @@ class ReolinkHostButtonEntityDescription( BUTTON_ENTITIES = ( ReolinkButtonEntityDescription( key="ptz_stop", - name="PTZ stop", + translation_key="ptz_stop", icon="mdi:pan", - supported=lambda api, ch: api.supported(ch, "pan_tilt"), + enabled_default=lambda api, ch: api.supported(ch, "pan_tilt"), + supported=lambda api, ch: api.supported(ch, "pan_tilt") + or api.supported(ch, "zoom_basic"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), ), ReolinkButtonEntityDescription( key="ptz_left", - name="PTZ left", + translation_key="ptz_left", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), ), ReolinkButtonEntityDescription( key="ptz_right", - name="PTZ right", + translation_key="ptz_right", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), ), ReolinkButtonEntityDescription( key="ptz_up", - name="PTZ up", + translation_key="ptz_up", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), ), ReolinkButtonEntityDescription( key="ptz_down", - name="PTZ down", + translation_key="ptz_down", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), ), + ReolinkButtonEntityDescription( + key="ptz_zoom_in", + translation_key="ptz_zoom_in", + icon="mdi:magnify", + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "zoom_basic"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomin.value), + ), + ReolinkButtonEntityDescription( + key="ptz_zoom_out", + translation_key="ptz_zoom_out", + icon="mdi:magnify", + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "zoom_basic"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomout.value), + ), ReolinkButtonEntityDescription( key="ptz_calibrate", - name="PTZ calibrate", + translation_key="ptz_calibrate", icon="mdi:pan", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ptz_callibrate"), @@ -100,14 +119,14 @@ BUTTON_ENTITIES = ( ), ReolinkButtonEntityDescription( key="guard_go_to", - name="Guard go to", + translation_key="guard_go_to", icon="mdi:crosshairs-gps", supported=lambda api, ch: api.supported(ch, "ptz_guard"), method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.goto.value), ), ReolinkButtonEntityDescription( key="guard_set", - name="Guard set current position", + translation_key="guard_set", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ptz_guard"), @@ -169,6 +188,10 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): self._attr_unique_id = ( f"{self._host.unique_id}_{channel}_{entity_description.key}" ) + if entity_description.enabled_default is not None: + self._attr_entity_registry_enabled_default = ( + entity_description.enabled_default(self._host.api, self._channel) + ) async def async_press(self) -> None: """Execute the button action.""" diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index d24fd8d1f14..d924f395c50 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -12,13 +12,14 @@ from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, 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 config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost +from .util import has_connection_problem _LOGGER = logging.getLogger(__name__) @@ -96,7 +97,46 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) - await self.async_set_unique_id(mac_address) + existing_entry = await self.async_set_unique_id(mac_address) + if ( + existing_entry + and CONF_PASSWORD in existing_entry.data + and existing_entry.data[CONF_HOST] != discovery_info.ip + ): + if has_connection_problem(self.hass, existing_entry): + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but connection to camera seems to be okay, so sticking to IP '%s'", + discovery_info.ip, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + + # check if the camera is reachable at the new IP + host = ReolinkHost(self.hass, existing_entry.data, existing_entry.options) + try: + await host.api.get_state("GetLocalLink") + await host.api.logout() + except ReolinkError as err: + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but got error '%s' trying to connect, so sticking to IP '%s'", + discovery_info.ip, + str(err), + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") from err + if format_mac(host.api.mac_address) != mac_address: + _LOGGER.debug( + "Reolink mac address '%s' at new IP '%s' from DHCP, " + "does not match mac '%s' of config entry, so sticking to IP '%s'", + format_mac(host.api.mac_address), + discovery_info.ip, + mac_address, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self.context["title_placeholders"] = { diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 48652eac21a..e7d62c9705a 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -5,8 +5,7 @@ from typing import TypeVar from reolink_aio.api import DUAL_LENS_MODELS -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index d505d92b8a9..a43dbce9a7c 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -10,7 +10,7 @@ import aiohttp from aiohttp.web import Request from reolink_aio.api import Host from reolink_aio.enums import SubType -from reolink_aio.exceptions import ReolinkError, SubscriptionError +from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -61,6 +61,7 @@ class ReolinkHost: ) self.webhook_id: str | None = None + self._onvif_supported: bool = True self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: bool = False @@ -96,6 +97,8 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + self._onvif_supported = self._api.supported(None, "ONVIF") + enable_rtsp = None enable_onvif = None enable_rtmp = None @@ -106,7 +109,7 @@ class ReolinkHost: ) enable_rtsp = True - if not self._api.onvif_enabled: + if not self._api.onvif_enabled and self._onvif_supported: _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) @@ -154,21 +157,34 @@ class ReolinkHost: self._unique_id = format_mac(self._api.mac_address) - await self.subscribe() - - if self._api.supported(None, "initial_ONVIF_state"): + if self._onvif_supported: + try: + await self.subscribe() + except NotSupportedError: + self._onvif_supported = False + self.unregister_webhook() + await self._api.unsubscribe() + else: + if self._api.supported(None, "initial_ONVIF_state"): + _LOGGER.debug( + "Waiting for initial ONVIF state on webhook '%s'", + self._webhook_url, + ) + else: + _LOGGER.debug( + "Camera model %s most likely does not push its initial state" + " upon ONVIF subscription, do not check", + self._api.model, + ) + self._cancel_onvif_check = async_call_later( + self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif + ) + if not self._onvif_supported: _LOGGER.debug( - "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url - ) - else: - _LOGGER.debug( - "Camera model %s most likely does not push its initial state" - " upon ONVIF subscription, do not check", + "Camera model %s does not support ONVIF, using fast polling instead", self._api.model, ) - self._cancel_onvif_check = async_call_later( - self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif - ) + await self._async_poll_all_motion() if self._api.sw_version_update_required: ir.async_create_issue( @@ -231,6 +247,7 @@ class ReolinkHost: "network_link": "https://my.home-assistant.io/redirect/network/", }, ) + if self._base_url.startswith("https"): ir.async_create_issue( self._hass, @@ -246,9 +263,28 @@ class ReolinkHost: ) else: ir.async_delete_issue(self._hass, DOMAIN, "https_webhook") + + if self._hass.config.api is not None and self._hass.config.api.use_ssl: + ir.async_create_issue( + self._hass, + DOMAIN, + "ssl", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="ssl", + translation_placeholders={ + "ssl_link": "https://www.home-assistant.io/integrations/http/#ssl_certificate", + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + "nginx_link": "https://github.com/home-assistant/addons/tree/master/nginx_proxy", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "ssl") else: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") ir.async_delete_issue(self._hass, DOMAIN, "https_webhook") + ir.async_delete_issue(self._hass, DOMAIN, "ssl") # If no ONVIF push or long polling state is received, start fast polling await self._async_poll_all_motion() @@ -345,6 +381,9 @@ class ReolinkHost: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" + if not self._onvif_supported: + return + try: await self._renew(SubType.push) if self._long_poll_task is not None: diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 0f80215d506..4ac8166410f 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -45,7 +45,7 @@ class ReolinkLightEntityDescription( LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", - name="Floodlight", + translation_key="floodlight", icon="mdi:spotlight-beam", supported_fn=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), @@ -55,7 +55,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="ir_lights", - name="Infra red lights in night mode", + translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), @@ -64,7 +64,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="status_led", - name="Status LED", + translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, supported_fn=lambda api, ch: api.supported(ch, "power_led"), diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 3ff25d1e7a0..060490c6e56 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.8"] + "requirements": ["reolink-aio==0.7.9"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index bb19974114d..24e5d1bd72b 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -45,7 +45,7 @@ class ReolinkNumberEntityDescription( NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", - name="Zoom", + translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, native_step=1, @@ -57,7 +57,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="focus", - name="Focus", + translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, native_step=1, @@ -72,7 +72,7 @@ NUMBER_ENTITIES = ( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", - name="Floodlight turn on brightness", + translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, native_step=1, @@ -84,7 +84,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="volume", - name="Volume", + translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, native_step=1, @@ -96,7 +96,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="guard_return_time", - name="Guard return time", + translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, native_step=1, @@ -109,7 +109,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="motion_sensitivity", - name="Motion sensitivity", + translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, native_step=1, @@ -121,7 +121,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", - name="AI face sensitivity", + translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, native_step=1, @@ -135,7 +135,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", - name="AI person sensitivity", + translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, native_step=1, @@ -149,7 +149,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", - name="AI vehicle sensitivity", + translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, native_step=1, @@ -163,7 +163,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", - name="AI pet sensitivity", + translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, native_step=1, @@ -175,9 +175,73 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), ), + ReolinkNumberEntityDescription( + key="ai_face_delay", + translation_key="ai_face_delay", + icon="mdi:face-recognition", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "face") + ), + value=lambda api, ch: api.ai_delay(ch, "face"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "face"), + ), + ReolinkNumberEntityDescription( + key="ai_person_delay", + translation_key="ai_person_delay", + icon="mdi:account", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "people") + ), + value=lambda api, ch: api.ai_delay(ch, "people"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "people"), + ), + ReolinkNumberEntityDescription( + key="ai_vehicle_delay", + translation_key="ai_vehicle_delay", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "vehicle") + ), + value=lambda api, ch: api.ai_delay(ch, "vehicle"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "vehicle"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_delay", + translation_key="ai_pet_delay", + icon="mdi:dog-side", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "dog_cat") + ), + value=lambda api, ch: api.ai_delay(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "dog_cat"), + ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", - name="Auto quick reply time", + translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, native_step=1, @@ -190,7 +254,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", - name="Auto track limit left", + translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, @@ -203,7 +267,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", - name="Auto track limit right", + translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, @@ -216,7 +280,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", - name="Auto track disappear time", + translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, native_step=1, @@ -231,7 +295,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", - name="Auto track stop time", + translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, native_step=1, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 2ae3442278e..84d39b3d8e2 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from typing import Any from reolink_aio.api import ( @@ -23,6 +24,8 @@ from . import ReolinkData from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity +_LOGGER = logging.getLogger(__name__) + @dataclass class ReolinkSelectEntityDescriptionMixin: @@ -45,10 +48,9 @@ class ReolinkSelectEntityDescription( SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", - name="Floodlight mode", + translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, - translation_key="floodlight_mode", get_options=lambda api, ch: api.whiteled_mode_list(ch), supported=lambda api, ch: api.supported(ch, "floodLight"), value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name, @@ -56,10 +58,9 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="day_night_mode", - name="Day night mode", + translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, - translation_key="day_night_mode", get_options=[mode.name for mode in DayNightEnum], supported=lambda api, ch: api.supported(ch, "dayNight"), value=lambda api, ch: DayNightEnum(api.daynight_state(ch)).name, @@ -67,7 +68,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="ptz_preset", - name="PTZ preset", + translation_key="ptz_preset", icon="mdi:pan", get_options=lambda api, ch: list(api.ptz_presets(ch)), supported=lambda api, ch: api.supported(ch, "ptz_presets"), @@ -75,9 +76,8 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", - name="Auto quick reply message", - icon="mdi:message-reply-text-outline", translation_key="auto_quick_reply_message", + icon="mdi:message-reply-text-outline", get_options=lambda api, ch: list(api.quick_reply_dict(ch).values()), supported=lambda api, ch: api.supported(ch, "quick_reply"), value=lambda api, ch: api.quick_reply_dict(ch)[api.quick_reply_file(ch)], @@ -87,9 +87,8 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_track_method", - name="Auto track method", - icon="mdi:target-account", translation_key="auto_track_method", + icon="mdi:target-account", entity_category=EntityCategory.CONFIG, get_options=[method.name for method in TrackMethodEnum], supported=lambda api, ch: api.supported(ch, "auto_track_method"), @@ -98,9 +97,8 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="status_led", - name="Status LED", - icon="mdi:lightning-bolt-circle", translation_key="status_led", + icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, get_options=[state.name for state in StatusLedEnum], supported=lambda api, ch: api.supported(ch, "doorbell_led"), @@ -140,6 +138,7 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): """Initialize Reolink select entity.""" super().__init__(reolink_data, channel) self.entity_description = entity_description + self._log_error = True self._attr_unique_id = ( f"{self._host.unique_id}_{channel}_{entity_description.key}" @@ -156,7 +155,16 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): if self.entity_description.value is None: return None - return self.entity_description.value(self._host.api, self._channel) + try: + option = self.entity_description.value(self._host.api, self._channel) + except ValueError: + if self._log_error: + _LOGGER.exception("Reolink '%s' has an unknown value", self.name) + self._log_error = False + return None + + self._log_error = True + return option async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index af8d049dbc6..6282f29e442 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -21,14 +21,30 @@ from homeassistant.helpers.typing import StateType from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkHostCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity + + +@dataclass +class ReolinkSensorEntityDescriptionMixin: + """Mixin values for Reolink sensor entities for a camera channel.""" + + value: Callable[[Host, int], int] + + +@dataclass +class ReolinkSensorEntityDescription( + SensorEntityDescription, ReolinkSensorEntityDescriptionMixin +): + """A class that describes sensor entities for a camera channel.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True @dataclass class ReolinkHostSensorEntityDescriptionMixin: """Mixin values for Reolink host sensor entities.""" - value: Callable[[Host], bool] + value: Callable[[Host], int] @dataclass @@ -37,9 +53,21 @@ class ReolinkHostSensorEntityDescription( ): """A class that describes host sensor entities.""" - supported: Callable[[Host], bool] = lambda host: True + supported: Callable[[Host], bool] = lambda api: True +SENSORS = ( + ReolinkSensorEntityDescription( + key="ptz_pan_position", + translation_key="ptz_pan_position", + icon="mdi:pan", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda api, ch: api.ptz_pan_position(ch), + supported=lambda api, ch: api.supported(ch, "ptz_position"), + ), +) + HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", @@ -62,11 +90,45 @@ async def async_setup_entry( """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - ReolinkHostSensorEntity(reolink_data, entity_description) - for entity_description in HOST_SENSORS - if entity_description.supported(reolink_data.host.api) + entities: list[ReolinkSensorEntity | ReolinkHostSensorEntity] = [ + ReolinkSensorEntity(reolink_data, channel, entity_description) + for entity_description in SENSORS + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + [ + ReolinkHostSensorEntity(reolink_data, entity_description) + for entity_description in HOST_SENSORS + if entity_description.supported(reolink_data.host.api) + ] ) + async_add_entities(entities) + + +class ReolinkSensorEntity(ReolinkChannelCoordinatorEntity, SensorEntity): + """Base sensor class for Reolink IP camera sensors.""" + + entity_description: ReolinkSensorEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSensorEntityDescription, + ) -> None: + """Initialize Reolink sensor.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + @property + def native_value(self) -> StateType | date | datetime | Decimal: + """Return the value reported by the sensor.""" + return self.entity_description.value(self._host.api, self._channel) class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity): @@ -79,7 +141,7 @@ class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity): reolink_data: ReolinkData, entity_description: ReolinkHostSensorEntityDescription, ) -> None: - """Initialize Reolink binary sensor.""" + """Initialize Reolink host sensor.""" super().__init__(reolink_data) self.entity_description = entity_description diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index 9dba3b840ea..c91f633ecab 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -33,7 +33,7 @@ class ReolinkSirenEntityDescription(SirenEntityDescription): SIREN_ENTITIES = ( ReolinkSirenEntityDescription( key="siren", - name="Siren", + translation_key="siren", icon="mdi:alarm-light", supported=lambda api, ch: api.supported(ch, "siren_play"), ), diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 2389c433b20..95aa26a1ff5 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -44,6 +44,10 @@ "title": "Reolink webhook URL uses HTTPS (SSL)", "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" }, + "ssl": { + "title": "Reolink incompatible with global SSL certificate", + "description": "Global SSL certificate configured in the [configuration.yaml under http]({ssl_link}) while a local HTTP address `{base_url}` is configured under \"Home Assistant URL\" in the [network settings]({network_link}). Therefore, the Reolink device can not reach Home Assistant to push its motion/AI events. Please make sure the local HTTP address is not covered by the SSL certificate, by for instance using [NGINX add-on]({nginx_link}) instead of a globally enforced SSL certificate." + }, "webhook_url": { "title": "Reolink webhook URL unreachable", "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received." @@ -58,8 +62,164 @@ } }, "entity": { + "binary_sensor": { + "face": { + "name": "Face" + }, + "person": { + "name": "Person" + }, + "vehicle": { + "name": "Vehicle" + }, + "pet": { + "name": "Pet" + }, + "visitor": { + "name": "Visitor" + }, + "motion_lens_0": { + "name": "Motion lens 0" + }, + "face_lens_0": { + "name": "Face lens 0" + }, + "person_lens_0": { + "name": "Person lens 0" + }, + "vehicle_lens_0": { + "name": "Vehicle lens 0" + }, + "pet_lens_0": { + "name": "Pet lens 0" + }, + "visitor_lens_0": { + "name": "Visitor lens 0" + }, + "motion_lens_1": { + "name": "Motion lens 1" + }, + "face_lens_1": { + "name": "Face lens 1" + }, + "person_lens_1": { + "name": "Person lens 1" + }, + "vehicle_lens_1": { + "name": "Vehicle lens 1" + }, + "pet_lens_1": { + "name": "Pet lens 1" + }, + "visitor_lens_1": { + "name": "Visitor lens 1" + } + }, + "button": { + "ptz_stop": { + "name": "PTZ stop" + }, + "ptz_left": { + "name": "PTZ left" + }, + "ptz_right": { + "name": "PTZ right" + }, + "ptz_up": { + "name": "PTZ up" + }, + "ptz_down": { + "name": "PTZ down" + }, + "ptz_zoom_in": { + "name": "PTZ zoom in" + }, + "ptz_zoom_out": { + "name": "PTZ zoom out" + }, + "ptz_calibrate": { + "name": "PTZ calibrate" + }, + "guard_go_to": { + "name": "Guard go to" + }, + "guard_set": { + "name": "Guard set current position" + } + }, + "light": { + "floodlight": { + "name": "Floodlight" + }, + "ir_lights": { + "name": "Infra red lights in night mode" + }, + "status_led": { + "name": "Status LED" + } + }, + "number": { + "zoom": { + "name": "Zoom" + }, + "focus": { + "name": "Focus" + }, + "floodlight_brightness": { + "name": "Floodlight turn on brightness" + }, + "volume": { + "name": "Volume" + }, + "guard_return_time": { + "name": "Guard return time" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "ai_face_sensititvity": { + "name": "AI face sensitivity" + }, + "ai_person_sensititvity": { + "name": "AI person sensitivity" + }, + "ai_vehicle_sensititvity": { + "name": "AI vehicle sensitivity" + }, + "ai_pet_sensititvity": { + "name": "AI pet sensitivity" + }, + "ai_face_delay": { + "name": "AI face delay" + }, + "ai_person_delay": { + "name": "AI person delay" + }, + "ai_vehicle_delay": { + "name": "AI vehicle delay" + }, + "ai_pet_delay": { + "name": "AI pet delay" + }, + "auto_quick_reply_time": { + "name": "Auto quick reply time" + }, + "auto_track_limit_left": { + "name": "Auto track limit left" + }, + "auto_track_limit_right": { + "name": "Auto track limit right" + }, + "auto_track_disappear_time": { + "name": "Auto track disappear time" + }, + "auto_track_stop_time": { + "name": "Auto track stop time" + } + }, "select": { "floodlight_mode": { + "name": "Floodlight mode", "state": { "off": "[%key:common::state::off%]", "auto": "Auto", @@ -69,18 +229,24 @@ } }, "day_night_mode": { + "name": "Day night mode", "state": { "auto": "Auto", "color": "Color", "blackwhite": "Black&White" } }, + "ptz_preset": { + "name": "PTZ preset" + }, "auto_quick_reply_message": { + "name": "Auto quick reply message", "state": { "off": "[%key:common::state::off%]" } }, "auto_track_method": { + "name": "Auto track method", "state": { "digital": "Digital", "digitalfirst": "Digital first", @@ -88,6 +254,7 @@ } }, "status_led": { + "name": "Status LED", "state": { "stayoff": "Stay off", "auto": "Auto", @@ -98,6 +265,49 @@ "sensor": { "wifi_signal": { "name": "Wi-Fi signal" + }, + "ptz_pan_position": { + "name": "PTZ pan position" + } + }, + "siren": { + "siren": { + "name": "[%key:component::siren::title%]" + } + }, + "switch": { + "record_audio": { + "name": "Record audio" + }, + "siren_on_event": { + "name": "Siren on event" + }, + "auto_tracking": { + "name": "Auto tracking" + }, + "auto_focus": { + "name": "Auto focus" + }, + "gaurd_return": { + "name": "Guard return" + }, + "email": { + "name": "Email on event" + }, + "ftp_upload": { + "name": "FTP upload" + }, + "push_notifications": { + "name": "Push notifications" + }, + "record": { + "name": "Record" + }, + "buzzer": { + "name": "Buzzer on event" + }, + "doorbell_button_sound": { + "name": "Doorbell button sound" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index aa121911758..4a5b415a144 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -55,7 +55,7 @@ class ReolinkNVRSwitchEntityDescription( SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="record_audio", - name="Record audio", + translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "audio"), @@ -64,7 +64,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="siren_on_event", - name="Siren on event", + translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "siren"), @@ -73,7 +73,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_tracking", - name="Auto tracking", + translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "auto_track"), @@ -82,7 +82,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_focus", - name="Auto focus", + translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "auto_focus"), @@ -91,7 +91,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="gaurd_return", - name="Guard return", + translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ptz_guard"), @@ -100,7 +100,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="email", - name="Email on event", + translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "email") and api.is_nvr, @@ -109,7 +109,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="ftp_upload", - name="FTP upload", + translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ftp") and api.is_nvr, @@ -118,7 +118,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="push_notifications", - name="Push notifications", + translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "push") and api.is_nvr, @@ -127,7 +127,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="record", - name="Record", + translation_key="record", icon="mdi:record-rec", supported=lambda api, ch: api.supported(ch, "recording") and api.is_nvr, value=lambda api, ch: api.recording_enabled(ch), @@ -135,7 +135,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="buzzer", - name="Buzzer on event", + translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "buzzer") and api.is_nvr, @@ -144,7 +144,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", - name="Doorbell button sound", + translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "doorbell_button_sound"), @@ -156,7 +156,7 @@ SWITCH_ENTITIES = ( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", - name="Email on event", + translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "email"), @@ -165,7 +165,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", - name="FTP upload", + translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "ftp"), @@ -174,7 +174,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", - name="Push notifications", + translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "push"), @@ -183,7 +183,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="record", - name="Record", + translation_key="record", icon="mdi:record-rec", supported=lambda api: api.supported(None, "recording"), value=lambda api: api.recording_enabled(), @@ -191,7 +191,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="buzzer", - name="Buzzer on event", + translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "buzzer"), diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index fbbb037080b..57efe1d9e92 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -41,7 +41,6 @@ class ReolinkUpdateEntity( _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_release_url = "https://reolink.com/download-center/" - _attr_name = "Update" def __init__( self, diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py new file mode 100644 index 00000000000..2ab625647a7 --- /dev/null +++ b/homeassistant/components/reolink/util.py @@ -0,0 +1,23 @@ +"""Utility functions for the Reolink component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant + +from . import ReolinkData +from .const import DOMAIN + + +def has_connection_problem( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Check if a existing entry has a connection problem.""" + reolink_data: ReolinkData | None = hass.data.get(DOMAIN, {}).get( + config_entry.entry_id + ) + connection_problem = ( + reolink_data is not None + and config_entry.state == config_entries.ConfigEntryState.LOADED + and reolink_data.device_coordinator.last_update_success + ) + return connection_problem diff --git a/homeassistant/components/repairs/websocket_api.py b/homeassistant/components/repairs/websocket_api.py index c5408054318..0c6230e4c35 100644 --- a/homeassistant/components/repairs/websocket_api.py +++ b/homeassistant/components/repairs/websocket_api.py @@ -12,6 +12,7 @@ from homeassistant import data_entry_flow from homeassistant.auth.permissions.const import POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.http.decorators import require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( @@ -88,6 +89,7 @@ class RepairsFlowIndexView(FlowManagerIndexView): url = "/api/repairs/issues/fix" name = "api:repairs:issues:fix" + @require_admin(error=Unauthorized(permission=POLICY_EDIT)) @RequestDataValidator( vol.Schema( { @@ -99,9 +101,6 @@ class RepairsFlowIndexView(FlowManagerIndexView): ) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(permission=POLICY_EDIT) - try: result = await self._flow_mgr.async_init( data["handler"], @@ -125,18 +124,12 @@ class RepairsFlowResourceView(FlowManagerResourceView): url = "/api/repairs/issues/fix/{flow_id}" name = "api:repairs:issues:fix:resource" - async def get(self, request: web.Request, flow_id: str) -> web.Response: + @require_admin(error=Unauthorized(permission=POLICY_EDIT)) + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" - if not request["hass_user"].is_admin: - raise Unauthorized(permission=POLICY_EDIT) - return await super().get(request, flow_id) - # pylint: disable=arguments-differ + @require_admin(error=Unauthorized(permission=POLICY_EDIT)) async def post(self, request: web.Request, flow_id: str) -> web.Response: """Handle a POST request.""" - if not request["hass_user"].is_admin: - raise Unauthorized(permission=POLICY_EDIT) - - # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 784555e6c73..578ca58b80f 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -1,7 +1,6 @@ """Support for monitoring Repetier Server Sensors.""" from __future__ import annotations -from datetime import datetime import logging import time @@ -10,6 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL, RepetierSensorEntityDescription @@ -170,7 +170,7 @@ class RepetierJobEndSensor(RepetierSensor): print_time = data["print_time"] from_start = data["from_start"] time_end = start + round(print_time, 0) - self._state = datetime.utcfromtimestamp(time_end) + self._state = dt_util.utc_from_timestamp(time_end) remaining = print_time - from_start remaining_secs = int(round(remaining, 0)) _LOGGER.debug( @@ -192,7 +192,7 @@ class RepetierJobStartSensor(RepetierSensor): job_name = data["job_name"] start = data["start"] from_start = data["from_start"] - self._state = datetime.utcfromtimestamp(start) + self._state = dt_util.utc_from_timestamp(start) elapsed_secs = int(round(from_start, 0)) _LOGGER.debug( "Job %s elapsed %s", diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 0c1f4df6093..8c629e2240e 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -14,6 +14,8 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, + CONF_ICON, + CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, CONF_UNIQUE_ID, @@ -24,7 +26,11 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import TemplateEntity +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -42,6 +48,14 @@ PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), PLATFORM_SCHEMA ) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, +) + async def async_setup_platform( hass: HomeAssistant, @@ -74,7 +88,14 @@ async def async_setup_platform( raise PlatformNotReady from rest.last_exception raise PlatformNotReady - unique_id = conf.get(CONF_UNIQUE_ID) + name = conf.get(CONF_NAME) or Template(DEFAULT_BINARY_SENSOR_NAME, hass) + + trigger_entity_config = {CONF_NAME: name} + + for key in TRIGGER_ENTITY_OPTIONS: + if key not in conf: + continue + trigger_entity_config[key] = conf[key] async_add_entities( [ @@ -83,13 +104,13 @@ async def async_setup_platform( coordinator, rest, conf, - unique_id, + trigger_entity_config, ) ], ) -class RestBinarySensor(RestEntity, TemplateEntity, BinarySensorEntity): +class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): """Representation of a REST binary sensor.""" def __init__( @@ -98,9 +119,10 @@ class RestBinarySensor(RestEntity, TemplateEntity, BinarySensorEntity): coordinator: DataUpdateCoordinator[None] | None, rest: RestData, config: ConfigType, - unique_id: str | None, + trigger_entity_config: ConfigType, ) -> None: """Initialize a REST binary sensor.""" + ManualTriggerEntity.__init__(self, hass, trigger_entity_config) RestEntity.__init__( self, coordinator, @@ -108,19 +130,17 @@ class RestBinarySensor(RestEntity, TemplateEntity, BinarySensorEntity): config.get(CONF_RESOURCE_TEMPLATE), config[CONF_FORCE_UPDATE], ) - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=DEFAULT_BINARY_SENSOR_NAME, - unique_id=unique_id, - ) self._previous_data = None self._value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) if (value_template := self._value_template) is not None: value_template.hass = hass - self._attr_device_class = config.get(CONF_DEVICE_CLASS) + @property + def available(self) -> bool: + """Return if entity is available.""" + available1 = RestEntity.available.fget(self) # type: ignore[attr-defined] + available2 = ManualTriggerEntity.available.fget(self) # type: ignore[attr-defined] + return bool(available1 and available2) def _update_from_rest_data(self) -> None: """Update state from the rest data.""" @@ -130,6 +150,8 @@ class RestBinarySensor(RestEntity, TemplateEntity, BinarySensorEntity): response = self.rest.data + raw_value = response + if self._value_template is not None: response = self._value_template.async_render_with_possible_json_value( self.rest.data, False @@ -144,3 +166,6 @@ class RestBinarySensor(RestEntity, TemplateEntity, BinarySensorEntity): "open": True, "yes": True, }.get(response.lower(), False) + + self._process_manual_data(raw_value) + self.async_write_ha_state() diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 1f331651165..61c88a14400 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -81,7 +81,7 @@ class RestData: "REST xml result could not be parsed and converted to JSON" ) else: - _LOGGER.debug("JSON converted from XML: %s", self.data) + _LOGGER.debug("JSON converted from XML: %s", value) return value async def async_update(self, log_errors: bool = True) -> None: diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index b6ec7eb8ecb..c8796c7161c 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -1,7 +1,7 @@ { "domain": "rest", "name": "RESTful", - "codeowners": ["@epenet"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/rest", "iot_class": "local_polling", "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"] diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index c5abe42d7fc..d6011a43efd 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -27,7 +27,8 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, TEMPLATE_ENTITY_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA, ) @@ -75,6 +76,7 @@ SENSOR_SCHEMA = { vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_AVAILABILITY): cv.template, } BINARY_SENSOR_SCHEMA = { @@ -82,6 +84,7 @@ BINARY_SENSOR_SCHEMA = { vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + vol.Optional(CONF_AVAILABILITY): cv.template, } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 18d0b6c7e76..67f70a716b0 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -3,37 +3,47 @@ from __future__ import annotations import logging import ssl +from typing import Any -from jsonpath import jsonpath import voluptuous as vol from homeassistant.components.sensor import ( + CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, + CONF_ICON, + CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.template_entity import TemplateSensor +from homeassistant.helpers.template import Template +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerSensorEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util.json import json_loads from . import async_get_config_and_coordinator, create_rest_data_from_config from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, DEFAULT_SENSOR_NAME from .data import RestData from .entity import RestEntity from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA +from .util import parse_json_attributes _LOGGER = logging.getLogger(__name__) @@ -43,6 +53,16 @@ PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), PLATFORM_SCHEMA ) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + async def async_setup_platform( hass: HomeAssistant, @@ -75,7 +95,14 @@ async def async_setup_platform( raise PlatformNotReady from rest.last_exception raise PlatformNotReady - unique_id: str | None = conf.get(CONF_UNIQUE_ID) + name = conf.get(CONF_NAME) or Template(DEFAULT_SENSOR_NAME, hass) + + trigger_entity_config = {CONF_NAME: name} + + for key in TRIGGER_ENTITY_OPTIONS: + if key not in conf: + continue + trigger_entity_config[key] = conf[key] async_add_entities( [ @@ -84,13 +111,13 @@ async def async_setup_platform( coordinator, rest, conf, - unique_id, + trigger_entity_config, ) ], ) -class RestSensor(RestEntity, TemplateSensor): +class RestSensor(ManualTriggerSensorEntity, RestEntity): """Implementation of a REST sensor.""" def __init__( @@ -99,9 +126,10 @@ class RestSensor(RestEntity, TemplateSensor): coordinator: DataUpdateCoordinator[None] | None, rest: RestData, config: ConfigType, - unique_id: str | None, + trigger_entity_config: ConfigType, ) -> None: """Initialize the REST sensor.""" + ManualTriggerSensorEntity.__init__(self, hass, trigger_entity_config) RestEntity.__init__( self, coordinator, @@ -109,51 +137,35 @@ class RestSensor(RestEntity, TemplateSensor): config.get(CONF_RESOURCE_TEMPLATE), config[CONF_FORCE_UPDATE], ) - TemplateSensor.__init__( - self, - hass, - config=config, - fallback_name=DEFAULT_SENSOR_NAME, - unique_id=unique_id, - ) self._value_template = config.get(CONF_VALUE_TEMPLATE) if (value_template := self._value_template) is not None: value_template.hass = hass self._json_attrs = config.get(CONF_JSON_ATTRS) self._json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) + self._attr_extra_state_attributes = {} + + @property + def available(self) -> bool: + """Return if entity is available.""" + available1 = RestEntity.available.fget(self) # type: ignore[attr-defined] + available2 = ManualTriggerSensorEntity.available.fget(self) # type: ignore[attr-defined] + return bool(available1 and available2) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra attributes.""" + return dict(self._attr_extra_state_attributes) def _update_from_rest_data(self) -> None: """Update state from the rest data.""" value = self.rest.data_without_xml() if self._json_attrs: - self._attr_extra_state_attributes = {} - if value: - try: - json_dict = json_loads(value) - if self._json_attrs_path is not None: - json_dict = jsonpath(json_dict, self._json_attrs_path) - # jsonpath will always store the result in json_dict[0] - # so the next line happens to work exactly as needed to - # find the result - if isinstance(json_dict, list): - json_dict = json_dict[0] - if isinstance(json_dict, dict): - attrs = { - k: json_dict[k] for k in self._json_attrs if k in json_dict - } - self._attr_extra_state_attributes = attrs - else: - _LOGGER.warning( - "JSON result was not a dictionary" - " or list with 0th element a dictionary" - ) - except ValueError: - _LOGGER.warning("REST result could not be parsed as JSON") - _LOGGER.debug("Erroneous JSON: %s", value) + self._attr_extra_state_attributes = parse_json_attributes( + value, self._json_attrs, self._json_attrs_path + ) - else: - _LOGGER.warning("Empty reply found when expecting JSON data") + raw_value = value if value is not None and self._value_template is not None: value = self._value_template.async_render_with_possible_json_value( @@ -165,8 +177,13 @@ class RestSensor(RestEntity, TemplateSensor): SensorDeviceClass.TIMESTAMP, ): self._attr_native_value = value + self._process_manual_data(raw_value) + self.async_write_ha_state() return self._attr_native_value = async_parse_date_datetime( value, self.entity_id, self.device_class ) + + self._process_manual_data(raw_value) + self.async_write_ha_state() diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 342808f3250..102bb024924 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -6,7 +6,6 @@ from http import HTTPStatus import logging from typing import Any -import async_timeout import httpx import voluptuous as vol @@ -18,7 +17,9 @@ from homeassistant.components.switch import ( from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_HEADERS, + CONF_ICON, CONF_METHOD, + CONF_NAME, CONF_PARAMS, CONF_PASSWORD, CONF_RESOURCE, @@ -32,9 +33,11 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, + ManualTriggerEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -44,6 +47,14 @@ CONF_BODY_ON = "body_on" CONF_IS_ON_TEMPLATE = "is_on_template" CONF_STATE_RESOURCE = "state_resource" +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, +) + DEFAULT_METHOD = "post" DEFAULT_BODY_OFF = "OFF" DEFAULT_BODY_ON = "ON" @@ -71,6 +82,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, vol.Inclusive(CONF_PASSWORD, "authentication"): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_AVAILABILITY): cv.template, } ) @@ -83,10 +95,17 @@ async def async_setup_platform( ) -> None: """Set up the RESTful switch.""" resource: str = config[CONF_RESOURCE] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + name = config.get(CONF_NAME) or template.Template(DEFAULT_NAME, hass) + + trigger_entity_config = {CONF_NAME: name} + + for key in TRIGGER_ENTITY_OPTIONS: + if key not in config: + continue + trigger_entity_config[key] = config[key] try: - switch = RestSwitch(hass, config, unique_id) + switch = RestSwitch(hass, config, trigger_entity_config) req = await switch.get_device_state(hass) if req.status_code >= HTTPStatus.BAD_REQUEST: @@ -102,23 +121,17 @@ async def async_setup_platform( raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc -class RestSwitch(TemplateEntity, SwitchEntity): +class RestSwitch(ManualTriggerEntity, SwitchEntity): """Representation of a switch that can be toggled using REST.""" def __init__( self, hass: HomeAssistant, config: ConfigType, - unique_id: str | None, + trigger_entity_config: ConfigType, ) -> None: """Initialize the REST switch.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=DEFAULT_NAME, - unique_id=unique_id, - ) + ManualTriggerEntity.__init__(self, hass, trigger_entity_config) auth: httpx.BasicAuth | None = None username: str | None = None @@ -138,8 +151,6 @@ class RestSwitch(TemplateEntity, SwitchEntity): self._timeout: int = config[CONF_TIMEOUT] self._verify_ssl: bool = config[CONF_VERIFY_SSL] - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._body_on.hass = hass self._body_off.hass = hass if (is_on_template := self._is_on_template) is not None: @@ -148,6 +159,11 @@ class RestSwitch(TemplateEntity, SwitchEntity): template.attach(hass, self._headers) template.attach(hass, self._params) + async def async_added_to_hass(self) -> None: + """Handle adding to Home Assistant.""" + await super().async_added_to_hass() + await self.async_update() + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" body_on_t = self._body_on.async_render(parse_result=False) @@ -186,11 +202,11 @@ class RestSwitch(TemplateEntity, SwitchEntity): rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with async_timeout.timeout(self._timeout): + async with asyncio.timeout(self._timeout): req: httpx.Response = await getattr(websession, self._method)( self._resource, auth=self._auth, - data=bytes(body, "utf-8"), + content=bytes(body, "utf-8"), headers=rendered_headers, params=rendered_params, ) @@ -198,13 +214,18 @@ class RestSwitch(TemplateEntity, SwitchEntity): async def async_update(self) -> None: """Get the current state, catching errors.""" + req = None try: - await self.get_device_state(self.hass) + req = await self.get_device_state(self.hass) except asyncio.TimeoutError: _LOGGER.exception("Timed out while fetching data") except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) + if req: + self._process_manual_data(req.text) + self.async_write_ha_state() + async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" websession = get_async_client(hass, self._verify_ssl) @@ -212,7 +233,7 @@ class RestSwitch(TemplateEntity, SwitchEntity): rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) - async with async_timeout.timeout(self._timeout): + async with asyncio.timeout(self._timeout): req = await websession.get( self._state_resource, auth=self._auth, diff --git a/homeassistant/components/rest/util.py b/homeassistant/components/rest/util.py new file mode 100644 index 00000000000..5625be3897a --- /dev/null +++ b/homeassistant/components/rest/util.py @@ -0,0 +1,40 @@ +"""Helpers for RESTful API.""" + +import logging +from typing import Any + +from jsonpath import jsonpath + +from homeassistant.util.json import json_loads + +_LOGGER = logging.getLogger(__name__) + + +def parse_json_attributes( + value: str | None, json_attrs: list[str], json_attrs_path: str | None +) -> dict[str, Any]: + """Parse JSON attributes.""" + if not value: + _LOGGER.warning("Empty reply found when expecting JSON data") + return {} + + try: + json_dict = json_loads(value) + if json_attrs_path is not None: + json_dict = jsonpath(json_dict, json_attrs_path) + # jsonpath will always store the result in json_dict[0] + # so the next line happens to work exactly as needed to + # find the result + if isinstance(json_dict, list): + json_dict = json_dict[0] + if isinstance(json_dict, dict): + return {k: json_dict[k] for k in json_attrs if k in json_dict} + + _LOGGER.warning( + "JSON result was not a dictionary or list with 0th element a dictionary" + ) + except ValueError: + _LOGGER.warning("REST result could not be parsed as JSON") + _LOGGER.debug("Erroneous JSON: %s", value) + + return {} diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 8df2d7ec343..60e2b0fef58 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -5,7 +5,6 @@ import asyncio from collections import defaultdict import logging -import async_timeout from rflink.protocol import ProtocolBase, create_rflink_connection from serial import SerialException import voluptuous as vol @@ -280,7 +279,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) try: - async with async_timeout.timeout(CONNECTION_TIMEOUT): + async with asyncio.timeout(CONNECTION_TIMEOUT): transport, protocol = await connection except ( diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 3544abcfdd1..9c5ffa586cd 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -8,7 +8,6 @@ import copy import logging from typing import Any, NamedTuple, cast -import async_timeout import RFXtrx as rfxtrxmod import voluptuous as vol @@ -25,11 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -164,7 +164,7 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: config = entry.data # Initialize library - async with async_timeout.timeout(30): + async with asyncio.timeout(30): rfx_object = await hass.async_add_executor_job(_create_rfx, config) # Setup some per device config diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 8d55208cbb7..179dd04cfaa 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -8,7 +8,6 @@ import itertools import os from typing import Any, TypedDict, cast -from async_timeout import timeout import RFXtrx as rfxtrxmod import serial import serial.tools.list_ports @@ -374,7 +373,7 @@ class OptionsFlow(config_entries.OptionsFlow): # Wait for entities to finish cleanup with suppress(asyncio.TimeoutError): - async with timeout(10): + async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() @@ -409,7 +408,7 @@ class OptionsFlow(config_entries.OptionsFlow): # Wait for entities to finish renaming with suppress(asyncio.TimeoutError): - async with timeout(10): + async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() diff --git a/homeassistant/components/ridwell/entity.py b/homeassistant/components/ridwell/entity.py index 9c7ceee7f56..095ecc3c5c6 100644 --- a/homeassistant/components/ridwell/entity.py +++ b/homeassistant/components/ridwell/entity.py @@ -5,8 +5,7 @@ from datetime import date from aioridwell.model import RidwellAccount, RidwellPickupEvent -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 1eba555e955..e4626831d7d 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -27,7 +27,7 @@ ATTR_QUANTITY = "quantity" SENSOR_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Next Ridwell pickup", + translation_key="next_pickup", device_class=SensorDeviceClass.DATE, ) diff --git a/homeassistant/components/ridwell/strings.json b/homeassistant/components/ridwell/strings.json index 3f4cc1806a4..c3cf6365860 100644 --- a/homeassistant/components/ridwell/strings.json +++ b/homeassistant/components/ridwell/strings.json @@ -24,5 +24,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "next_pickup": { + "name": "Next pickup" + } + }, + "switch": { + "opt_in": { + "name": "Opt-in to next pickup" + } + } } } diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index 7a948f8b883..f47fc1ca0af 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -16,11 +16,9 @@ from .const import DOMAIN from .coordinator import RidwellDataUpdateCoordinator from .entity import RidwellEntity -SWITCH_TYPE_OPT_IN = "opt_in" - SWITCH_DESCRIPTION = SwitchEntityDescription( - key=SWITCH_TYPE_OPT_IN, - name="Opt-in to next pickup", + key="opt_in", + translation_key="opt_in", icon="mdi:calendar-check", ) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 5fc438c2390..2b345b3b703 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,6 +1,7 @@ """Base class for Ring entity.""" from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from . import ATTRIBUTION, DOMAIN diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index 116e022d216..a72efe1629c 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LocalData, RiscoDataUpdateCoordinator, is_local @@ -88,6 +88,8 @@ class RiscoAlarm(AlarmControlPanelEntity): """Representation of a Risco cloud partition.""" _attr_code_format = CodeFormat.NUMBER + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -107,8 +109,6 @@ class RiscoAlarm(AlarmControlPanelEntity): self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] - self._attr_has_entity_name = True - self._attr_name = None for state in self._ha_to_risco: self._attr_supported_features |= STATES_TO_SUPPORTED_FEATURES[state] diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index 423137d88b6..f60b0bf3c35 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -50,14 +50,13 @@ class RiscoCloudBinarySensor(RiscoCloudZoneEntity, BinarySensorEntity): """Representation of a Risco cloud zone as a binary sensor.""" _attr_device_class = BinarySensorDeviceClass.MOTION + _attr_name = None def __init__( self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: Zone ) -> None: """Init the zone.""" - super().__init__( - coordinator=coordinator, name=None, suffix="", zone_id=zone_id, zone=zone - ) + super().__init__(coordinator=coordinator, suffix="", zone_id=zone_id, zone=zone) @property def is_on(self) -> bool | None: @@ -69,12 +68,11 @@ class RiscoLocalBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): """Representation of a Risco local zone as a binary sensor.""" _attr_device_class = BinarySensorDeviceClass.MOTION + _attr_name = None def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" - super().__init__( - system_id=system_id, name=None, suffix="", zone_id=zone_id, zone=zone - ) + super().__init__(system_id=system_id, suffix="", zone_id=zone_id, zone=zone) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -93,11 +91,12 @@ class RiscoLocalBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): class RiscoLocalAlarmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): """Representation whether a zone in Risco local is currently triggering an alarm.""" + _attr_translation_key = "alarmed" + def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" super().__init__( system_id=system_id, - name="Alarmed", suffix="_alarmed", zone_id=zone_id, zone=zone, @@ -112,11 +111,12 @@ class RiscoLocalAlarmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): class RiscoLocalArmedBinarySensor(RiscoLocalZoneEntity, BinarySensorEntity): """Representation whether a zone in Risco local is currently armed.""" + _attr_translation_key = "armed" + def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" super().__init__( system_id=system_id, - name="Armed", suffix="_armed", zone_id=zone_id, zone=zone, diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index a4ac260887c..7f8e3be698b 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -5,8 +5,9 @@ from typing import Any from pyrisco.common import Zone +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RiscoDataUpdateCoordinator, zone_update_signal @@ -55,7 +56,6 @@ class RiscoCloudZoneEntity(RiscoCloudEntity): self, *, coordinator: RiscoDataUpdateCoordinator, - name: str | None, suffix: str, zone_id: int, zone: Zone, @@ -65,7 +65,6 @@ class RiscoCloudZoneEntity(RiscoCloudEntity): super().__init__(coordinator=coordinator, **kwargs) self._zone_id = zone_id self._zone = zone - self._attr_name = name device_unique_id = zone_unique_id(self._risco, zone_id) self._attr_unique_id = f"{device_unique_id}{suffix}" self._attr_device_info = DeviceInfo( @@ -89,7 +88,6 @@ class RiscoLocalZoneEntity(Entity): self, *, system_id: str, - name: str | None, suffix: str, zone_id: int, zone: Zone, @@ -99,7 +97,6 @@ class RiscoLocalZoneEntity(Entity): super().__init__(**kwargs) self._zone_id = zone_id self._zone = zone - self._attr_name = name device_unique_id = f"{system_id}_zone_{zone_id}_local" self._attr_unique_id = f"{device_unique_id}{suffix}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/risco/strings.json b/homeassistant/components/risco/strings.json index ed3d832cf0b..13dfd60b5b6 100644 --- a/homeassistant/components/risco/strings.json +++ b/homeassistant/components/risco/strings.json @@ -64,5 +64,20 @@ } } } + }, + "entity": { + "binary_sensor": { + "alarmed": { + "name": "Alarmed" + }, + "armed": { + "name": "Armed" + } + }, + "switch": { + "bypassed": { + "name": "Bypassed" + } + } } } diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index f0804abb68a..9b34479f8a2 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -40,6 +40,7 @@ class RiscoCloudSwitch(RiscoCloudZoneEntity, SwitchEntity): """Representation of a bypass switch for a Risco cloud zone.""" _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "bypassed" def __init__( self, coordinator: RiscoDataUpdateCoordinator, zone_id: int, zone: Zone @@ -47,7 +48,6 @@ class RiscoCloudSwitch(RiscoCloudZoneEntity, SwitchEntity): """Init the zone.""" super().__init__( coordinator=coordinator, - name="Bypassed", suffix="_bypassed", zone_id=zone_id, zone=zone, @@ -76,12 +76,12 @@ class RiscoLocalSwitch(RiscoLocalZoneEntity, SwitchEntity): """Representation of a bypass switch for a Risco local zone.""" _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "bypassed" def __init__(self, system_id: str, zone_id: int, zone: Zone) -> None: """Init the zone.""" super().__init__( system_id=system_id, - name="Bypassed", suffix="_bypassed", zone_id=zone_id, zone=zone, diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index 713c3905f05..83564f40488 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -1,7 +1,8 @@ """Base class for Rituals Perfume Genie diffuser entity.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6ba6f3915ec..0a9f42887a6 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -11,7 +11,7 @@ from roborock.local_api import RoborockLocalClient from roborock.roborock_typing import DeviceProp from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index c40e47ada99..27f25208a4e 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -9,7 +9,8 @@ from roborock.exceptions import RoborockException from roborock.roborock_typing import RoborockCommand from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RoborockDataUpdateCoordinator diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 818fd338ffb..0629839f01b 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -13,7 +13,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import AREA_SQUARE_METERS, 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 @@ -122,6 +127,13 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, options=RoborockErrorCode.keys(), ), + RoborockSensorDescription( + key="battery", + value_fn=lambda data: data.status.battery, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index cd629e208e3..5ca2292f804 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -150,6 +150,9 @@ "dnd_switch": { "name": "Do not disturb" }, + "off_peak_switch": { + "name": "Off-peak charging" + }, "status_indicator": { "name": "Status indicator light" } @@ -160,6 +163,12 @@ }, "dnd_end_time": { "name": "Do not disturb end" + }, + "off_peak_start_time": { + "name": "Off-peak start" + }, + "off_peak_end_time": { + "name": "Off-peak end" } }, "vacuum": { diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 312753ced01..de820ede8fa 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -84,6 +84,25 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, ), + RoborockSwitchDescription( + cache_key=CacheableAttribute.valley_electricity_timer, + update_value=lambda cache, value: cache.update_value( + [ + cache.value.get("start_hour"), + cache.value.get("start_minute"), + cache.value.get("end_hour"), + cache.value.get("end_minute"), + ] + ) + if value + else cache.close_value(), + 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 514d147d469..5dc98e09352 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -79,6 +79,44 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ ), entity_category=EntityCategory.CONFIG, ), + 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( + [ + desired_time.hour, + desired_time.minute, + cache.value.get("end_hour"), + cache.value.get("end_minute"), + ] + ), + get_value=lambda cache: datetime.time( + hour=cache.value.get("start_hour"), minute=cache.value.get("start_minute") + ), + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + 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( + [ + cache.value.get("start_hour"), + cache.value.get("start_minute"), + desired_time.hour, + desired_time.minute, + ] + ), + get_value=lambda cache: datetime.time( + hour=cache.value.get("end_hour"), minute=cache.value.get("end_minute") + ), + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), ] diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index 317364b262a..30bcc6c4515 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -23,7 +23,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): vol.Url(), diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 583d26a4a5b..f31a07feb29 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -22,7 +22,12 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Roku from a config entry.""" - coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) + if (device_id := entry.unique_id) is None: + device_id = entry.entry_id + + coordinator = RokuDataUpdateCoordinator( + hass, host=entry.data[CONF_HOST], device_id=device_id + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 4bc36d0f7e5..b08933dcd91 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -36,27 +36,27 @@ class RokuBinarySensorEntityDescription( BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = ( RokuBinarySensorEntityDescription( key="headphones_connected", - name="Headphones connected", + translation_key="headphones_connected", icon="mdi:headphones", value_fn=lambda device: device.info.headphones_connected, ), RokuBinarySensorEntityDescription( key="supports_airplay", - name="Supports AirPlay", + translation_key="supports_airplay", icon="mdi:cast-variant", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.supports_airplay, ), RokuBinarySensorEntityDescription( key="supports_ethernet", - name="Supports ethernet", + translation_key="supports_ethernet", icon="mdi:ethernet", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.ethernet_support, ), RokuBinarySensorEntityDescription( key="supports_find_remote", - name="Supports find remote", + translation_key="supports_find_remote", icon="mdi:remote", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.supports_find_remote, @@ -71,10 +71,9 @@ async def async_setup_entry( ) -> None: """Set up a Roku binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( RokuBinarySensorEntity( - device_id=unique_id, coordinator=coordinator, description=description, ) diff --git a/homeassistant/components/roku/coordinator.py b/homeassistant/components/roku/coordinator.py index f084302841e..a0bd9df238c 100644 --- a/homeassistant/components/roku/coordinator.py +++ b/homeassistant/components/roku/coordinator.py @@ -32,8 +32,10 @@ class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): hass: HomeAssistant, *, host: str, + device_id: str, ) -> None: """Initialize global Roku data updater.""" + self.device_id = device_id self.roku = Roku(host=host, session=async_get_clientsession(hass)) self.full_update_interval = timedelta(minutes=15) diff --git a/homeassistant/components/roku/entity.py b/homeassistant/components/roku/entity.py index a85024f8220..b783831d4ec 100644 --- a/homeassistant/components/roku/entity.py +++ b/homeassistant/components/roku/entity.py @@ -1,8 +1,8 @@ """Base Entity for Roku.""" from __future__ import annotations -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RokuDataUpdateCoordinator @@ -12,45 +12,37 @@ from .const import DOMAIN class RokuEntity(CoordinatorEntity[RokuDataUpdateCoordinator]): """Defines a base Roku entity.""" + _attr_has_entity_name = True + def __init__( self, *, - device_id: str | None, coordinator: RokuDataUpdateCoordinator, description: EntityDescription | None = None, ) -> None: """Initialize the Roku entity.""" super().__init__(coordinator) - self._device_id = device_id if description is not None: self.entity_description = description + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + else: + self._attr_unique_id = coordinator.device_id - if device_id is None: - self._attr_name = f"{coordinator.data.info.name} {description.name}" - - if device_id is not None: - self._attr_has_entity_name = True - - if description is not None: - self._attr_unique_id = f"{device_id}_{description.key}" - else: - self._attr_unique_id = device_id - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - connections={ - (CONNECTION_NETWORK_MAC, mac_address) - for mac_address in ( - self.coordinator.data.info.wifi_mac, - self.coordinator.data.info.ethernet_mac, - ) - if mac_address is not None - }, - name=self.coordinator.data.info.name, - manufacturer=self.coordinator.data.info.brand, - model=self.coordinator.data.info.model_name, - hw_version=self.coordinator.data.info.model_number, - sw_version=self.coordinator.data.info.version, - suggested_area=self.coordinator.data.info.device_location, - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + connections={ + (CONNECTION_NETWORK_MAC, mac_address) + for mac_address in ( + self.coordinator.data.info.wifi_mac, + self.coordinator.data.info.ethernet_mac, + ) + if mac_address is not None + }, + name=self.coordinator.data.info.name, + manufacturer=self.coordinator.data.info.brand, + model=self.coordinator.data.info.model_name, + hw_version=self.coordinator.data.info.model_number, + sw_version=self.coordinator.data.info.version, + suggested_area=self.coordinator.data.info.device_location, + ) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index a8c1cf4698c..05f782b37c4 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -85,11 +85,10 @@ async def async_setup_entry( ) -> None: """Set up the Roku config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( [ RokuMediaPlayer( - device_id=unique_id, coordinator=coordinator, ) ], diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index 0271e4a0f73..ef5350eb741 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -22,11 +22,10 @@ async def async_setup_entry( ) -> None: """Load Roku remote based on a config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( [ RokuRemote( - device_id=unique_id, coordinator=coordinator, ) ], diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index e11748114d1..430133b7f77 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -95,7 +95,7 @@ class RokuSelectEntityDescription( ENTITIES: tuple[RokuSelectEntityDescription, ...] = ( RokuSelectEntityDescription( key="application", - name="Application", + translation_key="application", icon="mdi:application", set_fn=_launch_application, value_fn=_get_application_name, @@ -106,7 +106,7 @@ ENTITIES: tuple[RokuSelectEntityDescription, ...] = ( CHANNEL_ENTITY = RokuSelectEntityDescription( key="channel", - name="Channel", + translation_key="channel", icon="mdi:television", set_fn=_tune_channel, value_fn=_get_channel_name, @@ -122,14 +122,12 @@ async def async_setup_entry( """Set up Roku select based on a config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] device: RokuDevice = coordinator.data - unique_id = device.info.serial_number entities: list[RokuSelectEntity] = [] for description in ENTITIES: entities.append( RokuSelectEntity( - device_id=unique_id, coordinator=coordinator, description=description, ) @@ -138,7 +136,6 @@ async def async_setup_entry( if len(device.channels) > 0: entities.append( RokuSelectEntity( - device_id=unique_id, coordinator=coordinator, description=CHANNEL_ENTITY, ) diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 0f0e87205b9..69b8c34d312 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -34,14 +34,14 @@ class RokuSensorEntityDescription( SENSORS: tuple[RokuSensorEntityDescription, ...] = ( RokuSensorEntityDescription( key="active_app", - name="Active App", + translation_key="active_app", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:application", value_fn=lambda device: device.app.name if device.app else None, ), RokuSensorEntityDescription( key="active_app_id", - name="Active App ID", + translation_key="active_app_id", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:application-cog", value_fn=lambda device: device.app.app_id if device.app else None, @@ -56,10 +56,9 @@ async def async_setup_entry( ) -> None: """Set up Roku sensor based on a config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( RokuSensorEntity( - device_id=unique_id, coordinator=coordinator, description=description, ) diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 3510a43c604..818b43930f4 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -21,6 +21,38 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "entity": { + "binary_sensor": { + "headphones_connected": { + "name": "Headphones connected" + }, + "supports_airplay": { + "name": "Supports AirPlay" + }, + "supports_ethernet": { + "name": "Supports ethernet" + }, + "supports_find_remote": { + "name": "Supports find remote" + } + }, + "select": { + "application": { + "name": "Application" + }, + "channel": { + "name": "Channel" + } + }, + "sensor": { + "active_app": { + "name": "Active app" + }, + "active_app_id": { + "name": "Active app ID" + } + } + }, "services": { "search": { "name": "Search", diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 641c814d122..85dbbe14cdc 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -3,7 +3,6 @@ import asyncio from functools import partial import logging -import async_timeout from roombapy import RoombaConnectionError, RoombaFactory from homeassistant import exceptions @@ -86,7 +85,7 @@ async def async_connect_or_timeout(hass, roomba): """Connect to vacuum.""" try: name = None - async with async_timeout.timeout(10): + async with asyncio.timeout(10): _LOGGER.debug("Initialize connection to vacuum") await hass.async_add_executor_job(roomba.connect) while not roomba.roomba_connected or name is None: @@ -110,7 +109,7 @@ async def async_connect_or_timeout(hass, roomba): async def async_disconnect_or_timeout(hass, roomba): """Disconnect to vacuum.""" _LOGGER.debug("Disconnect vacuum") - async with async_timeout.timeout(3): + async with asyncio.timeout(3): await hass.async_add_executor_job(roomba.disconnect) return True diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 5dbd1e986f3..8b909392250 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -15,7 +15,8 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import STATE_IDLE, STATE_PAUSED import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 6d096ea8b1a..d56bacd67c4 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -19,11 +19,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index c04a193d2e1..644dcd499a0 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.20.24"] + "requirements": ["boto3==1.28.17"] } diff --git a/homeassistant/components/rova/const.py b/homeassistant/components/rova/const.py new file mode 100644 index 00000000000..71d39d3703b --- /dev/null +++ b/homeassistant/components/rova/const.py @@ -0,0 +1,8 @@ +"""Const file for Rova.""" +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_ZIP_CODE = "zip_code" +CONF_HOUSE_NUMBER = "house_number" +CONF_HOUSE_NUMBER_SUFFIX = "house_number_suffix" diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index f68ffbd0eaf..3565b3baf0d 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime, timedelta -import logging from requests.exceptions import ConnectTimeout, HTTPError from rova.rova import Rova @@ -22,10 +21,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle from homeassistant.util.dt import get_time_zone -# Config for rova requests. -CONF_ZIP_CODE = "zip_code" -CONF_HOUSE_NUMBER = "house_number" -CONF_HOUSE_NUMBER_SUFFIX = "house_number_suffix" +from .const import CONF_HOUSE_NUMBER, CONF_HOUSE_NUMBER_SUFFIX, CONF_ZIP_CODE, LOGGER UPDATE_DELAY = timedelta(hours=12) SCAN_INTERVAL = timedelta(hours=12) @@ -66,8 +62,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -_LOGGER = logging.getLogger(__name__) - def setup_platform( hass: HomeAssistant, @@ -87,10 +81,10 @@ def setup_platform( try: if not api.is_rova_area(): - _LOGGER.error("ROVA does not collect garbage in this area") + LOGGER.error("ROVA does not collect garbage in this area") return except (ConnectTimeout, HTTPError): - _LOGGER.error("Could not retrieve details from ROVA API") + LOGGER.error("Could not retrieve details from ROVA API") return # Create rova data service which will retrieve and update the data. @@ -140,7 +134,7 @@ class RovaData: try: items = self.api.get_calendar_items() except (ConnectTimeout, HTTPError): - _LOGGER.error("Could not retrieve data, retry again later") + LOGGER.error("Could not retrieve data, retry again later") return self.data = {} @@ -153,4 +147,4 @@ class RovaData: if code not in self.data: self.data[code] = date - _LOGGER.debug("Updated Rova calendar: %s", self.data) + LOGGER.debug("Updated Rova calendar: %s", self.data) diff --git a/homeassistant/components/rtsp_to_webrtc/__init__.py b/homeassistant/components/rtsp_to_webrtc/__init__.py index f5f114bce9c..77bf7ffeb8f 100644 --- a/homeassistant/components/rtsp_to_webrtc/__init__.py +++ b/homeassistant/components/rtsp_to_webrtc/__init__.py @@ -18,10 +18,10 @@ Other integrations may use this integration with these steps: from __future__ import annotations +import asyncio import logging from typing import Any -import async_timeout from rtsp_to_webrtc.client import get_adaptive_client from rtsp_to_webrtc.exceptions import ClientError, ResponseError from rtsp_to_webrtc.interface import WebRTCClientInterface @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client: WebRTCClientInterface try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): client = await get_adaptive_client( async_get_clientsession(hass), entry.data[DATA_SERVER_URL] ) @@ -71,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: the stream itself happens directly between the client and proxy. """ try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): return await client.offer_stream_id(stream_id, offer_sdp, stream_source) except TimeoutError as err: raise HomeAssistantError("Timeout talking to RTSPtoWebRTC server") from err diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index f276c0f8fc2..e71555598cb 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -1,21 +1,22 @@ """The Ruckus Unleashed integration.""" +import logging -from pyruckus import Ruckus +from aioruckus import AjaxSession +from aioruckus.exceptions import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import ( - API_AP, - API_DEVICE_NAME, - API_ID, - API_MAC, - API_MODEL, - API_SYSTEM_OVERVIEW, - API_VERSION, + API_AP_DEVNAME, + API_AP_FIRMWAREVERSION, + API_AP_MAC, + API_AP_MODEL, + API_SYS_SYSINFO, + API_SYS_SYSINFO_VERSION, COORDINATOR, DOMAIN, MANUFACTURER, @@ -24,35 +25,45 @@ from .const import ( ) from .coordinator import RuckusUnleashedDataUpdateCoordinator +_LOGGER = logging.getLogger(__package__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ruckus Unleashed from a config entry.""" + try: - ruckus = await Ruckus.create( + ruckus = AjaxSession.async_create( entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], ) - except ConnectionError as error: - raise ConfigEntryNotReady from error + await ruckus.login() + except (ConnectionRefusedError, ConnectionError) as conerr: + raise ConfigEntryNotReady from conerr + except AuthenticationError as autherr: + raise ConfigEntryAuthFailed from autherr coordinator = RuckusUnleashedDataUpdateCoordinator(hass, ruckus=ruckus) await coordinator.async_config_entry_first_refresh() - system_info = await ruckus.system_info() + system_info = await ruckus.api.get_system_info() registry = dr.async_get(hass) - ap_info = await ruckus.ap_info() - for device in ap_info[API_AP][API_ID].values(): + aps = await ruckus.api.get_aps() + for access_point in aps: + _LOGGER.debug("AP [%s] %s", access_point[API_AP_MAC], entry.entry_id) registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, - identifiers={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, + connections={(dr.CONNECTION_NETWORK_MAC, access_point[API_AP_MAC])}, + identifiers={(DOMAIN, access_point[API_AP_MAC])}, manufacturer=MANUFACTURER, - name=device[API_DEVICE_NAME], - model=device[API_MODEL], - sw_version=system_info[API_SYSTEM_OVERVIEW][API_VERSION], + name=access_point[API_AP_DEVNAME], + model=access_point[API_AP_MODEL], + sw_version=access_point.get( + API_AP_FIRMWAREVERSION, + system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_VERSION], + ), ) hass.data.setdefault(DOMAIN, {}) @@ -68,11 +79,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.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: for listener in hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS]: listener() - + await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py index 4adf245c3de..155eb68f593 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -1,22 +1,29 @@ """Config flow for Ruckus Unleashed integration.""" -import logging +from collections.abc import Mapping +from typing import Any -from pyruckus import Ruckus -from pyruckus.exceptions import AuthenticationError +from aioruckus import AjaxSession, SystemStat +from aioruckus.exceptions import AuthenticationError import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult -from .const import API_SERIAL, API_SYSTEM_OVERVIEW, DOMAIN - -_LOGGER = logging.getLogger(__package__) +from .const import ( + API_MESH_NAME, + API_SYS_SYSINFO, + API_SYS_SYSINFO_SERIAL, + DOMAIN, + KEY_SYS_SERIAL, + KEY_SYS_TITLE, +) DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("username"): str, - vol.Required("password"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -28,26 +35,22 @@ async def validate_input(hass: core.HomeAssistant, data): """ try: - ruckus = await Ruckus.create( + async with AjaxSession.async_create( data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] - ) - except AuthenticationError as error: - raise InvalidAuth from error - except ConnectionError as error: - raise CannotConnect from error - - mesh_name = await ruckus.mesh_name() - - system_info = await ruckus.system_info() - try: - host_serial = system_info[API_SYSTEM_OVERVIEW][API_SERIAL] - except KeyError as error: - raise CannotConnect from error - - return { - "title": mesh_name, - "serial": host_serial, - } + ) as ruckus: + system_info = await ruckus.api.get_system_info( + SystemStat.SYSINFO, + ) + mesh_name = (await ruckus.api.get_mesh_info())[API_MESH_NAME] + zd_serial = system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] + return { + KEY_SYS_TITLE: mesh_name, + KEY_SYS_SERIAL: zd_serial, + } + except AuthenticationError as autherr: + raise InvalidAuth from autherr + except (ConnectionRefusedError, ConnectionError, KeyError) as connerr: + raise CannotConnect from connerr class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -55,7 +58,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -65,18 +70,32 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" else: - await self.async_set_unique_id(info["serial"]) + await self.async_set_unique_id(info[KEY_SYS_SERIAL]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=info[KEY_SYS_TITLE], data=user_input + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + ) + return await self.async_step_user() + class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/ruckus_unleashed/const.py b/homeassistant/components/ruckus_unleashed/const.py index e6087be3fd2..089981348b6 100644 --- a/homeassistant/components/ruckus_unleashed/const.py +++ b/homeassistant/components/ruckus_unleashed/const.py @@ -3,23 +3,35 @@ from homeassistant.const import Platform DOMAIN = "ruckus_unleashed" PLATFORMS = [Platform.DEVICE_TRACKER] -SCAN_INTERVAL = 180 +SCAN_INTERVAL = 30 MANUFACTURER = "Ruckus" COORDINATOR = "coordinator" UNDO_UPDATE_LISTENERS = "undo_update_listeners" -API_CLIENTS = "clients" -API_NAME = "host_name" -API_MAC = "mac_address" -API_IP = "user_ip" -API_SYSTEM_OVERVIEW = "system_overview" -API_SERIAL = "serial_number" -API_DEVICE_NAME = "device_name" -API_MODEL = "model" -API_VERSION = "version" -API_AP = "ap" -API_ID = "id" -API_CURRENT_ACTIVE_CLIENTS = "current_active_clients" -API_ACCESS_POINT = "access_point" +KEY_SYS_CLIENTS = "clients" +KEY_SYS_TITLE = "title" +KEY_SYS_SERIAL = "serial" + +API_MESH_NAME = "name" +API_MESH_PSK = "psk" + +API_CLIENT_HOSTNAME = "hostname" +API_CLIENT_MAC = "mac" +API_CLIENT_IP = "ip" +API_CLIENT_AP_MAC = "ap" + +API_AP_MAC = "mac" +API_AP_SERIALNUMBER = "serial" +API_AP_DEVNAME = "devname" +API_AP_MODEL = "model" +API_AP_FIRMWAREVERSION = "version" + +API_SYS_SYSINFO = "sysinfo" +API_SYS_SYSINFO_VERSION = "version" +API_SYS_SYSINFO_SERIAL = "serial" +API_SYS_IDENTITY = "identity" +API_SYS_IDENTITY_NAME = "name" +API_SYS_UNLEASHEDNETWORK = "unleashed-network" +API_SYS_UNLEASHEDNETWORK_TOKEN = "unleashed-network-token" diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index e84b79ef843..29df676cb76 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -2,19 +2,13 @@ from datetime import timedelta import logging -from pyruckus import Ruckus -from pyruckus.exceptions import AuthenticationError +from aioruckus import AjaxSession +from aioruckus.exceptions import AuthenticationError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - API_CLIENTS, - API_CURRENT_ACTIVE_CLIENTS, - API_MAC, - DOMAIN, - SCAN_INTERVAL, -) +from .const import API_CLIENT_MAC, DOMAIN, KEY_SYS_CLIENTS, SCAN_INTERVAL _LOGGER = logging.getLogger(__package__) @@ -22,7 +16,7 @@ _LOGGER = logging.getLogger(__package__) class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator): """Coordinator to manage data from Ruckus Unleashed client.""" - def __init__(self, hass: HomeAssistant, *, ruckus: Ruckus) -> None: + def __init__(self, hass: HomeAssistant, *, ruckus: AjaxSession) -> None: """Initialize global Ruckus Unleashed data updater.""" self.ruckus = ruckus @@ -37,12 +31,15 @@ class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator): async def _fetch_clients(self) -> dict: """Fetch clients from the API and format them.""" - clients = await self.ruckus.current_active_clients() - return {e[API_MAC]: e for e in clients[API_CURRENT_ACTIVE_CLIENTS][API_CLIENTS]} + clients = await self.ruckus.api.get_active_clients() + _LOGGER.debug("fetched %d active clients", len(clients)) + return {client[API_CLIENT_MAC]: client for client in clients} async def _async_update_data(self) -> dict: """Fetch Ruckus Unleashed data.""" try: - return {API_CLIENTS: await self._fetch_clients()} - except (AuthenticationError, ConnectionError) as error: - raise UpdateFailed(error) from error + return {KEY_SYS_CLIENTS: await self._fetch_clients()} + except AuthenticationError as autherror: + raise UpdateFailed(autherror) from autherror + except (ConnectionRefusedError, ConnectionError) as conerr: + raise UpdateFailed(conerr) from conerr diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index dd6d7fd6764..0e0d2f103c4 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -1,6 +1,8 @@ """Support for Ruckus Unleashed devices.""" from __future__ import annotations +import logging + from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -9,14 +11,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - API_CLIENTS, - API_NAME, + API_CLIENT_HOSTNAME, + API_CLIENT_IP, COORDINATOR, DOMAIN, - MANUFACTURER, + KEY_SYS_CLIENTS, UNDO_UPDATE_LISTENERS, ) +_LOGGER = logging.getLogger(__package__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -46,12 +50,15 @@ def add_new_entities(coordinator, async_add_entities, tracked): """Add new tracker entities from the router.""" new_tracked = [] - for mac in coordinator.data[API_CLIENTS]: + for mac in coordinator.data[KEY_SYS_CLIENTS]: if mac in tracked: continue - device = coordinator.data[API_CLIENTS][mac] - new_tracked.append(RuckusUnleashedDevice(coordinator, mac, device[API_NAME])) + device = coordinator.data[KEY_SYS_CLIENTS][mac] + _LOGGER.debug("adding new device: [%s] %s", mac, device[API_CLIENT_HOSTNAME]) + new_tracked.append( + RuckusUnleashedDevice(coordinator, mac, device[API_CLIENT_HOSTNAME]) + ) tracked.add(mac) async_add_entities(new_tracked) @@ -66,7 +73,7 @@ def restore_entities(registry, coordinator, entry, async_add_entities, tracked): if ( entity.config_entry_id == entry.entry_id and entity.platform == DOMAIN - and entity.unique_id not in coordinator.data[API_CLIENTS] + and entity.unique_id not in coordinator.data[KEY_SYS_CLIENTS] ): missing.append( RuckusUnleashedDevice( @@ -75,6 +82,7 @@ def restore_entities(registry, coordinator, entry, async_add_entities, tracked): ) tracked.add(entity.unique_id) + _LOGGER.debug("added %d missing devices", len(missing)) async_add_entities(missing) @@ -95,17 +103,25 @@ class RuckusUnleashedDevice(CoordinatorEntity, ScannerEntity): @property def name(self) -> str: """Return the name.""" - if self.is_connected: - return ( - self.coordinator.data[API_CLIENTS][self._mac][API_NAME] - or f"{MANUFACTURER} {self._mac}" - ) - return self._name + return ( + self._name + if not self.is_connected + else self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_HOSTNAME] + ) + + @property + def ip_address(self) -> str: + """Return the ip address.""" + return ( + self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_IP] + if self.is_connected + else None + ) @property def is_connected(self) -> bool: """Return true if the device is connected to the network.""" - return self._mac in self.coordinator.data[API_CLIENTS] + return self._mac in self.coordinator.data[KEY_SYS_CLIENTS] @property def source_type(self) -> SourceType: diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 124268174b7..8ff69fb1aa9 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -1,10 +1,11 @@ { "domain": "ruckus_unleashed", "name": "Ruckus Unleashed", - "codeowners": ["@gabe565"], + "codeowners": ["@gabe565", "@lanrat"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed", + "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["pexpect", "pyruckus"], - "requirements": ["pyruckus==0.16"] + "loggers": ["aioruckus", "xmltodict"], + "requirements": ["aioruckus==0.31", "xmltodict==0.13.0"] } diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 2c1a3ecee11..35e4b155b28 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 1d85fcf0243..d4920ef77f3 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -12,13 +12,12 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, SIGNAL_SABNZBD_UPDATED -from .const import DEFAULT_NAME, KEY_API_DATA, KEY_NAME +from .const import DEFAULT_NAME, KEY_API_DATA @dataclass @@ -38,51 +37,51 @@ SPEED_KEY = "kbpersec" SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( SabnzbdSensorEntityDescription( key="status", - name="Status", + translation_key="status", ), SabnzbdSensorEntityDescription( key=SPEED_KEY, - name="Speed", + translation_key="speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="mb", - name="Queue", + translation_key="queue", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="mbleft", - name="Left", + translation_key="left", native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="diskspacetotal1", - name="Disk", + translation_key="total_disk_space", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="diskspace1", - name="Disk Free", + translation_key="free_disk_space", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, ), SabnzbdSensorEntityDescription( key="noofslots_total", - name="Queue Count", + translation_key="queue_count", state_class=SensorStateClass.TOTAL, ), SabnzbdSensorEntityDescription( key="day_size", - name="Daily Total", + translation_key="daily_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -90,7 +89,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( ), SabnzbdSensorEntityDescription( key="week_size", - name="Weekly Total", + translation_key="weekly_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( ), SabnzbdSensorEntityDescription( key="month_size", - name="Monthly Total", + translation_key="monthly_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_registry_enabled_default=False, @@ -106,7 +105,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( ), SabnzbdSensorEntityDescription( key="total_size", - name="Total", + translation_key="overall_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.TOTAL_INCREASING, @@ -138,13 +137,9 @@ async def async_setup_entry( entry_id = config_entry.entry_id sab_api_data = hass.data[DOMAIN][entry_id][KEY_API_DATA] - client_name = hass.data[DOMAIN][entry_id][KEY_NAME] async_add_entities( - [ - SabnzbdSensor(sab_api_data, client_name, sensor, entry_id) - for sensor in SENSOR_TYPES - ] + [SabnzbdSensor(sab_api_data, sensor, entry_id) for sensor in SENSOR_TYPES] ) @@ -153,11 +148,11 @@ class SabnzbdSensor(SensorEntity): entity_description: SabnzbdSensorEntityDescription _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, sabnzbd_api_data, - client_name, description: SabnzbdSensorEntityDescription, entry_id, ) -> None: @@ -166,7 +161,6 @@ class SabnzbdSensor(SensorEntity): self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description self._sabnzbd_api = sabnzbd_api_data - self._attr_name = f"{client_name} {description.name}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index a8e146eeb27..f8c831cd95a 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -14,6 +14,43 @@ "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" } }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "speed": { + "name": "Speed" + }, + "queue": { + "name": "Queue" + }, + "left": { + "name": "Left to download" + }, + "total_disk_space": { + "name": "Total disk space" + }, + "free_disk_space": { + "name": "Free disk space" + }, + "queue_count": { + "name": "Queue count" + }, + "daily_total": { + "name": "Daily total" + }, + "weekly_total": { + "name": "Weekly total" + }, + "monthly_total": { + "name": "Monthly total" + }, + "overall_total": { + "name": "Overall total" + } + } + }, "services": { "pause": { "name": "[%key:common::action::pause%]", diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 58dd4436861..12a5ae99570 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -141,7 +141,7 @@ async def async_setup_platform( @callback def stop_update_interval(event): """Properly cancel the scheduled update.""" - remove_interval_update() # pylint: disable=not-callable + remove_interval_update() hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, stop_update_interval) async_at_start(hass, start_update_interval) @@ -171,7 +171,7 @@ def async_track_time_interval_backoff( def remove_listener() -> None: """Remove interval listener.""" if remove: - remove() # pylint: disable=not-callable + remove() return remove_listener diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 0cc4dd556d5..03a9c35c9ba 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -548,7 +548,6 @@ class SamsungTVWSBridge( return RESULT_AUTH_MISSING except (ConnectionFailure, OSError, AsyncioTimeoutError) as err: LOGGER.debug("Failing config: %s, %s error: %s", config, type(err), err) - # pylint: disable-next=useless-else-on-loop else: # noqa: PLW0120 if result: return result diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 4d5ea3d5fab..e0ecbaac024 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -6,7 +6,8 @@ from typing import cast from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .bridge import SamsungTVBridge from .const import CONF_MANUFACTURER, DOMAIN diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index d32e71c71c0..9461eb86af6 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.34.1" + "async-upnp-client==0.35.0" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 2f82c979b94..06783314b4c 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Coroutine, Sequence from typing import Any -import async_timeout from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory @@ -217,7 +216,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): # enter it unless we have to (Python 3.11 will have zero cost try) return try: - async with async_timeout.timeout(APP_LIST_DELAY): + async with asyncio.timeout(APP_LIST_DELAY): await self._app_list_event.wait() except asyncio.TimeoutError as err: # No need to try again diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py new file mode 100644 index 00000000000..cf95e190e88 --- /dev/null +++ b/homeassistant/components/schlage/__init__.py @@ -0,0 +1,39 @@ +"""The Schlage integration.""" +from __future__ import annotations + +from pycognito.exceptions import WarrantException +import pyschlage + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, LOGGER +from .coordinator import SchlageDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR, Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Schlage from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + try: + auth = await hass.async_add_executor_job(pyschlage.Auth, username, password) + except WarrantException as ex: + LOGGER.error("Schlage authentication failed: %s", ex) + return False + + coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """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/schlage/config_flow.py b/homeassistant/components/schlage/config_flow.py new file mode 100644 index 00000000000..7e095466087 --- /dev/null +++ b/homeassistant/components/schlage/config_flow.py @@ -0,0 +1,58 @@ +"""Config flow for Schlage integration.""" +from __future__ import annotations + +from typing import Any + +import pyschlage +from pyschlage.exceptions import NotAuthorizedError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Schlage.""" + + 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: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + try: + user_id = await self.hass.async_add_executor_job( + _authenticate, username, password + ) + except NotAuthorizedError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_id) + return self.async_create_entry(title=username, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +def _authenticate(username: str, password: str) -> str: + """Authenticate with the Schlage API.""" + auth = pyschlage.Auth(username, password) + auth.authenticate() + # The user_id property will make a blocking call if it's not already + # cached. To avoid blocking the event loop, we read it here. + return auth.user_id diff --git a/homeassistant/components/schlage/const.py b/homeassistant/components/schlage/const.py new file mode 100644 index 00000000000..1effd4bb334 --- /dev/null +++ b/homeassistant/components/schlage/const.py @@ -0,0 +1,9 @@ +"""Constants for the Schlage integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "schlage" +LOGGER = logging.getLogger(__package__) +MANUFACTURER = "Schlage" +UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py new file mode 100644 index 00000000000..2b1e8460af2 --- /dev/null +++ b/homeassistant/components/schlage/coordinator.py @@ -0,0 +1,70 @@ +"""DataUpdateCoordinator for the Schlage integration.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from pyschlage import Lock, Schlage +from pyschlage.exceptions import Error as SchlageError +from pyschlage.log import LockLog + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL + + +@dataclass +class LockData: + """Container for cached lock data from the Schlage API.""" + + lock: Lock + logs: list[LockLog] + + +@dataclass +class SchlageData: + """Container for cached data from the Schlage API.""" + + locks: dict[str, LockData] + + +class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): + """The Schlage data update coordinator.""" + + def __init__(self, hass: HomeAssistant, username: str, api: Schlage) -> None: + """Initialize the class.""" + super().__init__( + hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL + ) + self.api = api + + async def _async_update_data(self) -> SchlageData: + """Fetch the latest data from the Schlage API.""" + try: + locks = await self.hass.async_add_executor_job(self.api.locks) + except SchlageError as ex: + raise UpdateFailed("Failed to refresh Schlage data") from ex + lock_data = await asyncio.gather( + *( + self.hass.async_add_executor_job(self._get_lock_data, lock) + for lock in locks + ) + ) + return SchlageData( + locks={ld.lock.device_id: ld for ld in lock_data}, + ) + + def _get_lock_data(self, lock: Lock) -> LockData: + logs: list[LockLog] = [] + previous_lock_data = None + if self.data and (previous_lock_data := self.data.locks.get(lock.device_id)): + # Default to the previous data, in case a refresh fails. + # It's not critical if we don't have the freshest data. + logs = previous_lock_data.logs + try: + logs = lock.logs() + except SchlageError as ex: + LOGGER.debug('Failed to read logs for lock "%s": %s', lock.name, ex) + + return LockData(lock=lock, logs=logs) diff --git a/homeassistant/components/schlage/entity.py b/homeassistant/components/schlage/entity.py new file mode 100644 index 00000000000..61bdbcb7730 --- /dev/null +++ b/homeassistant/components/schlage/entity.py @@ -0,0 +1,46 @@ +"""Base entity class for Schlage.""" + +from pyschlage.lock import Lock + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import LockData, SchlageDataUpdateCoordinator + + +class SchlageEntity(CoordinatorEntity[SchlageDataUpdateCoordinator]): + """Base Schlage entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: SchlageDataUpdateCoordinator, device_id: str + ) -> None: + """Initialize a Schlage entity.""" + super().__init__(coordinator=coordinator) + self.device_id = device_id + self._attr_unique_id = device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=self._lock.name, + manufacturer=MANUFACTURER, + model=self._lock.model_name, + sw_version=self._lock.firmware_version, + ) + + @property + def _lock_data(self) -> LockData: + """Fetch the LockData from our coordinator.""" + return self.coordinator.data.locks[self.device_id] + + @property + def _lock(self) -> Lock: + """Fetch the Schlage lock from our coordinator.""" + return self._lock_data.lock + + @property + def available(self) -> bool: + """Return if entity is available.""" + # When is_locked is None the lock is unavailable. + return super().available and self._lock.is_locked is not None diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py new file mode 100644 index 00000000000..ff9c60c0b55 --- /dev/null +++ b/homeassistant/components/schlage/lock.py @@ -0,0 +1,65 @@ +"""Platform for Schlage lock integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.lock import LockEntity +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 SchlageDataUpdateCoordinator +from .entity import SchlageEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Schlage WiFi locks based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + SchlageLockEntity(coordinator=coordinator, device_id=device_id) + for device_id in coordinator.data.locks + ) + + +class SchlageLockEntity(SchlageEntity, LockEntity): + """Schlage lock entity.""" + + _attr_name = None + + def __init__( + self, coordinator: SchlageDataUpdateCoordinator, device_id: str + ) -> None: + """Initialize a Schlage Lock.""" + super().__init__(coordinator=coordinator, device_id=device_id) + self._update_attrs() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attrs() + return super()._handle_coordinator_update() + + def _update_attrs(self) -> None: + """Update our internal state attributes.""" + self._attr_is_locked = self._lock.is_locked + self._attr_is_jammed = self._lock.is_jammed + # Only update changed_by if we get a valid value. This way a previous + # value will stay intact if the latest log message isn't related to a + # lock state change. + if changed_by := self._lock.last_changed_by(self._lock_data.logs): + self._attr_changed_by = changed_by + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device.""" + await self.hass.async_add_executor_job(self._lock.lock) + await self.coordinator.async_request_refresh() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the device.""" + await self.hass.async_add_executor_job(self._lock.unlock) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json new file mode 100644 index 00000000000..25316004c58 --- /dev/null +++ b/homeassistant/components/schlage/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "schlage", + "name": "Schlage", + "codeowners": ["@dknowles2"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/schlage", + "iot_class": "cloud_polling", + "requirements": ["pyschlage==2023.8.1"] +} diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py new file mode 100644 index 00000000000..2cf1694e111 --- /dev/null +++ b/homeassistant/components/schlage/sensor.py @@ -0,0 +1,68 @@ +"""Platform for Schlage sensor integration.""" + +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SchlageDataUpdateCoordinator +from .entity import SchlageEntity + +_SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="battery_level", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + SchlageBatterySensor( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + for description in _SENSOR_DESCRIPTIONS + for device_id in coordinator.data.locks + ) + + +class SchlageBatterySensor(SchlageEntity, SensorEntity): + """Schlage battery sensor entity.""" + + def __init__( + self, + coordinator: SchlageDataUpdateCoordinator, + description: SensorEntityDescription, + device_id: str, + ) -> None: + """Initialize a Schlage battery sensor.""" + super().__init__(coordinator=coordinator, device_id=device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + self._attr_native_value = getattr(self._lock, self.entity_description.key) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = getattr(self._lock, self.entity_description.key) + return super()._handle_coordinator_update() diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json new file mode 100644 index 00000000000..f3612bb96b8 --- /dev/null +++ b/homeassistant/components/schlage/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "switch": { + "beeper": { + "name": "Keypress Beep" + }, + "lock_and_leave": { + "name": "1-Touch Locking" + } + } + } +} diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py new file mode 100644 index 00000000000..1a4eeb7bcc7 --- /dev/null +++ b/homeassistant/components/schlage/switch.py @@ -0,0 +1,123 @@ +"""Platform for Schlage switch integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +from typing import Any + +from pyschlage.lock import Lock + +from homeassistant.components.switch import ( + SwitchDeviceClass, + 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 SchlageDataUpdateCoordinator +from .entity import SchlageEntity + + +@dataclass +class SchlageSwitchEntityDescriptionMixin: + """Mixin for required keys.""" + + # NOTE: This has to be a mixin because these are required keys. + # SwitchEntityDescription has attributes with default values, + # which means we can't inherit from it because you haven't have + # non-default arguments follow default arguments in an initializer. + + on_fn: Callable[[Lock], None] + off_fn: Callable[[Lock], None] + value_fn: Callable[[Lock], bool] + + +@dataclass +class SchlageSwitchEntityDescription( + SwitchEntityDescription, SchlageSwitchEntityDescriptionMixin +): + """Entity description for a Schlage switch.""" + + +SWITCHES: tuple[SchlageSwitchEntityDescription, ...] = ( + SchlageSwitchEntityDescription( + key="beeper", + translation_key="beeper", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + on_fn=lambda lock: lock.set_beeper(True), + off_fn=lambda lock: lock.set_beeper(False), + value_fn=lambda lock: lock.beeper_enabled, + ), + SchlageSwitchEntityDescription( + key="lock_and_leve", + translation_key="lock_and_leave", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + on_fn=lambda lock: lock.set_lock_and_leave(True), + off_fn=lambda lock: lock.set_lock_and_leave(False), + value_fn=lambda lock: lock.lock_and_leave_enabled, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for device_id in coordinator.data.locks: + for description in SWITCHES: + entities.append( + SchlageSwitch( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + ) + async_add_entities(entities) + + +class SchlageSwitch(SchlageEntity, SwitchEntity): + """Schlage switch entity.""" + + entity_description: SchlageSwitchEntityDescription + + def __init__( + self, + coordinator: SchlageDataUpdateCoordinator, + description: SchlageSwitchEntityDescription, + device_id: str, + ) -> None: + """Initialize a SchlageSwitch.""" + super().__init__(coordinator=coordinator, device_id=device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{self.entity_description.key}" + + @property + def is_on(self) -> bool: + """Return True if the switch is on.""" + return self.entity_description.value_fn(self._lock) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.hass.async_add_executor_job( + partial(self.entity_description.on_fn, self._lock) + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.hass.async_add_executor_job( + partial(self.entity_description.off_fn, self._lock) + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index bf2ccb16b03..bdfa3fd9c5a 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, ) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 3ca13e56b29..dc0254cc642 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, @@ -77,6 +78,7 @@ RESOURCE_SETUP = { vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) ), + vol.Optional(CONF_PAYLOAD): ObjectSelector(), vol.Optional(CONF_AUTHENTICATION): SelectSelector( SelectSelectorConfig( options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION], @@ -104,7 +106,7 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Required(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( options=[NONE_SENTINEL] + sorted( @@ -118,14 +120,14 @@ SENSOR_SETUP = { translation_key="device_class", ) ), - vol.Required(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, translation_key="state_class", ) ), - vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 42f9fdb05d5..26603603198 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -2,9 +2,9 @@ "domain": "scrape", "name": "Scrape", "after_dependencies": ["rest"], - "codeowners": ["@fabaff", "@gjohansson-ST", "@epenet"], + "codeowners": ["@fabaff", "@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.11.1", "lxml==4.9.3"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.3"] } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index cc4cd269606..77131ccb225 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -6,11 +6,7 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - SensorDeviceClass, - SensorEntity, -) +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -24,13 +20,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, ManualTriggerEntity, + ManualTriggerSensorEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -40,6 +38,16 @@ from .coordinator import ScrapeCoordinator _LOGGER = logging.getLogger(__name__) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + async def async_setup_platform( hass: HomeAssistant, @@ -62,25 +70,17 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass - trigger_entity_config = { - CONF_NAME: sensor_config[CONF_NAME], - CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), - CONF_UNIQUE_ID: sensor_config.get(CONF_UNIQUE_ID), - } - if available := sensor_config.get(CONF_AVAILABILITY): - trigger_entity_config[CONF_AVAILABILITY] = available - if icon := sensor_config.get(CONF_ICON): - trigger_entity_config[CONF_ICON] = icon - if picture := sensor_config.get(CONF_PICTURE): - trigger_entity_config[CONF_PICTURE] = picture + trigger_entity_config = {CONF_NAME: sensor_config[CONF_NAME]} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in sensor_config: + continue + trigger_entity_config[key] = sensor_config[key] entities.append( ScrapeSensor( hass, coordinator, trigger_entity_config, - sensor_config.get(CONF_UNIT_OF_MEASUREMENT), - sensor_config.get(CONF_STATE_CLASS), sensor_config[CONF_SELECT], sensor_config.get(CONF_ATTRIBUTE), sensor_config[CONF_INDEX], @@ -112,19 +112,17 @@ async def async_setup_entry( Template(value_string, hass) if value_string is not None else None ) - trigger_entity_config = { - CONF_NAME: name, - CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), - CONF_UNIQUE_ID: sensor_config[CONF_UNIQUE_ID], - } + trigger_entity_config = {CONF_NAME: name} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in sensor_config: + continue + trigger_entity_config[key] = sensor_config[key] entities.append( ScrapeSensor( hass, coordinator, trigger_entity_config, - sensor_config.get(CONF_UNIT_OF_MEASUREMENT), - sensor_config.get(CONF_STATE_CLASS), sensor_config[CONF_SELECT], sensor_config.get(CONF_ATTRIBUTE), sensor_config[CONF_INDEX], @@ -136,9 +134,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScrapeSensor( - CoordinatorEntity[ScrapeCoordinator], ManualTriggerEntity, SensorEntity -): +class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEntity): """Representation of a web scrape sensor.""" def __init__( @@ -146,8 +142,6 @@ class ScrapeSensor( hass: HomeAssistant, coordinator: ScrapeCoordinator, trigger_entity_config: ConfigType, - unit_of_measurement: str | None, - state_class: str | None, select: str, attr: str | None, index: int, @@ -156,18 +150,26 @@ class ScrapeSensor( ) -> None: """Initialize a web scrape sensor.""" CoordinatorEntity.__init__(self, coordinator) - ManualTriggerEntity.__init__(self, hass, trigger_entity_config) - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_state_class = state_class + ManualTriggerSensorEntity.__init__(self, hass, trigger_entity_config) self._select = select self._attr = attr self._index = index self._value_template = value_template self._attr_native_value = None + if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)): + self._attr_name = None + self._attr_has_entity_name = True + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, unique_id)}, + manufacturer="Scrape", + name=self.name, + ) def _extract_value(self) -> Any: """Parse the html extraction in the executor.""" raw_data = self.coordinator.data + value: str | list[str] | None try: if self._attr is not None: value = raw_data.select(self._select)[self._index][self._attr] diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 4301bb7d5a0..fc2d83dada4 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -16,6 +16,7 @@ "password": "[%key:common::config_flow::data::password%]", "headers": "Headers", "method": "Method", + "payload": "Payload", "timeout": "Timeout", "encoding": "Character encoding" }, @@ -25,7 +26,8 @@ "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", "headers": "Headers to use for the web request", "timeout": "Timeout for connection to website", - "encoding": "Character encoding to use. Defaults to UTF-8" + "encoding": "Character encoding to use. Defaults to UTF-8", + "payload": "Payload to use when method is POST" } }, "sensor": { @@ -107,6 +109,7 @@ "data": { "resource": "[%key:component::scrape::config::step::user::data::resource%]", "method": "[%key:component::scrape::config::step::user::data::method%]", + "payload": "[%key:component::scrape::config::step::user::data::payload%]", "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", "username": "[%key:component::scrape::config::step::user::data::username%]", "password": "[%key:component::scrape::config::step::user::data::password%]", @@ -121,7 +124,8 @@ "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]", - "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]" + "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]", + "payload": "[%key:component::scrape::config::step::user::data_description::payload%]" } } } diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index eb006b55367..955b73262a1 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -9,7 +9,7 @@ from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ScreenlogicDataUpdateCoordinator diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 8530aa3b04c..13b25a00053 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -563,7 +563,8 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): ) coro = self._async_run(variables, context) if wait: - return await coro + script_result = await coro + return script_result.service_response if script_result else None # Caller does not want to wait for called script to finish so let script run in # separate Task. Make a new empty script stack; scripts are allowed to diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index 4d78e60db0f..cfca3c1f9ea 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -9,8 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index 4c47f497b36..25d00fdd3b8 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -29,7 +29,6 @@ CONF_SENDER_NAME = "sender_name" DEFAULT_SENDER_NAME = "Home Assistant" -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index d6679d80f69..5440372cbc8 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -13,8 +13,8 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 4ff63a25455..da86ba8fe24 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -54,6 +54,22 @@ ATTR_HORIZONTAL_SWING_MODE = "horizontal_swing_mode" ATTR_LIGHT = "light" BOOST_INCLUSIVE = "boost_inclusive" +AVAILABLE_FAN_MODES = {"quiet", "low", "medium", "medium_high", "high", "auto"} +AVAILABLE_SWING_MODES = { + "stopped", + "fixedtop", + "fixedmiddletop", + "fixedmiddle", + "fixedmiddlebottom", + "fixedbottom", + "rangetop", + "rangemiddle", + "rangebottom", + "rangefull", + "horizontal", + "both", +} + PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { @@ -178,6 +194,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): ) self._attr_supported_features = self.get_features() self._attr_precision = PRECISION_TENTHS + self._attr_translation_key = "climate_device" def get_features(self) -> ClimateEntityFeature: """Get supported features.""" @@ -309,6 +326,10 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Set new target fan mode.""" if "fanLevel" not in self.device_data.active_features: raise HomeAssistantError("Current mode doesn't support setting Fanlevel") + if fan_mode not in AVAILABLE_FAN_MODES: + raise HomeAssistantError( + f"Climate fan mode {fan_mode} is not supported by the integration, please open an issue" + ) transformation = self.device_data.fan_modes_translated await self.async_send_api_call( @@ -350,6 +371,10 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Set new target swing operation.""" if "swing" not in self.device_data.active_features: raise HomeAssistantError("Current mode doesn't support setting Swing") + if swing_mode not in AVAILABLE_SWING_MODES: + raise HomeAssistantError( + f"Climate swing mode {swing_mode} is not supported by the integration, please open an issue" + ) transformation = self.device_data.swing_modes_translated await self.async_send_api_call( diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 3696f618fd7..4eff1a011a5 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -1,15 +1,14 @@ """Base entity for Sensibo integration.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar -import async_timeout from pysensibo.model import MotionSensor, SensiboDevice from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER, SENSIBO_ERRORS, TIMEOUT @@ -28,7 +27,7 @@ def async_handle_api_call( """Wrap services for api calls.""" res: bool = False try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): res = await function(*args, **kwargs) except SENSIBO_ERRORS as err: raise HomeAssistantError from err diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 38ae94d4fa3..a6f14b73ace 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -79,7 +79,9 @@ "fixedright": "Fixed right", "fixedleftright": "Fixed left right", "rangecenter": "Range center", - "rangefull": "Range full" + "rangefull": "Range full", + "rangeleft": "Range left", + "rangeright": "Range right" } }, "light": { @@ -338,6 +340,38 @@ "fw_ver_available": { "name": "Update available" } + }, + "climate": { + "climate_device": { + "state_attributes": { + "fan_mode": { + "state": { + "quiet": "Quiet", + "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium_high": "Medium high", + "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", + "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" + } + }, + "swing_mode": { + "state": { + "stopped": "[%key:common::state::off%]", + "fixedtop": "Fixed top", + "fixedmiddletop": "Fixed middle top", + "fixedmiddle": "Fixed middle", + "fixedmiddlebottom": "Fixed middle bottom", + "fixedbottom": "Fixed bottom", + "rangetop": "Range top", + "rangemiddle": "Range middle", + "rangebottom": "Range bottom", + "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", + "horizontal": "Horizontal", + "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" + } + } + } + } } }, "services": { diff --git a/homeassistant/components/sensibo/util.py b/homeassistant/components/sensibo/util.py index 9070be3412a..98b843a9dfc 100644 --- a/homeassistant/components/sensibo/util.py +++ b/homeassistant/components/sensibo/util.py @@ -1,7 +1,8 @@ """Utils for Sensibo integration.""" from __future__ import annotations -import async_timeout +import asyncio + from pysensibo import SensiboClient from pysensibo.exceptions import AuthenticationError @@ -20,7 +21,7 @@ async def async_validate_api(hass: HomeAssistant, api_key: str) -> str: ) try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): device_query = await client.async_get_devices() user_query = await client.async_get_me() except AuthenticationError as err: diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index cbdaa24ec83..b8151256519 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -5,12 +5,10 @@ import asyncio from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation as DecimalInvalidOperation import logging -from math import ceil, floor, log10 -import re -import sys +from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final from homeassistant.config_entries import ConfigEntry @@ -89,10 +87,6 @@ _LOGGER: Final = logging.getLogger(__name__) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" -NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$") - -PY_311 = sys.version_info >= (3, 11, 0) - SCAN_INTERVAL: Final = timedelta(seconds=30) __all__ = [ @@ -534,8 +528,8 @@ class SensorEntity(Entity): "which is missing timezone information" ) - if value.tzinfo != timezone.utc: - value = value.astimezone(timezone.utc) + if value.tzinfo != UTC: + value = value.astimezone(UTC) return value.isoformat(timespec="seconds") except (AttributeError, OverflowError, TypeError) as err: @@ -588,7 +582,11 @@ class SensorEntity(Entity): if not isinstance(value, (int, float, Decimal)): try: if isinstance(value, str) and "." not in value and "e" not in value: - numerical_value = int(value) + try: + numerical_value = int(value) + except ValueError: + # Handle nan, inf + numerical_value = float(value) else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: @@ -602,6 +600,15 @@ class SensorEntity(Entity): else: numerical_value = value + if not isfinite(numerical_value): + raise ValueError( + f"Sensor {self.entity_id} has device class '{device_class}', " + f"state class '{state_class}' unit '{unit_of_measurement}' and " + f"suggested precision '{suggested_precision}' thus indicating it " + f"has a numeric value; however, it has the non-finite value: " + f"'{numerical_value}'" + ) + if native_unit_of_measurement != unit_of_measurement and ( converter := UNIT_CONVERTERS.get(device_class) ): @@ -636,12 +643,7 @@ class SensorEntity(Entity): ) precision = precision + floor(ratio_log) - if PY_311: - value = f"{converted_numerical_value:z.{precision}f}" - else: - value = f"{converted_numerical_value:.{precision}f}" - if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): - value = value[1:] + value = f"{converted_numerical_value:z.{precision}f}" else: value = converted_numerical_value @@ -903,11 +905,6 @@ def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> st with suppress(TypeError, ValueError): numerical_value = float(value) - if PY_311: - value = f"{numerical_value:z.{precision}f}" - else: - value = f"{numerical_value:.{precision}f}" - if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): - value = value[1:] + value = f"{numerical_value:z.{precision}f}" return value diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2b75c1114ce..e5a35187c99 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -149,7 +149,7 @@ def _equivalent_units(units: set[str | None]) -> bool: def _parse_float(state: str) -> float: """Parse a float string, throw on inf or nan.""" fstate = float(state) - if math.isnan(fstate) or math.isinf(fstate): + if not math.isfinite(fstate): raise ValueError return fstate diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 479acd8ac1e..e12bf0e48c6 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -110,7 +110,9 @@ async def async_setup_entry( SensorPushBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class SensorPushBluetoothSensorEntity( diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index 0c49368001d..a94941ac642 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -14,7 +14,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 9e8201bc1b5..79533576efb 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index 13a1563034f..c9418bcc2e9 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -19,7 +19,7 @@ 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 import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index c01d298daff..1c4540b1c74 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index b6cae8ad605..f80e7acf9a6 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -2,7 +2,6 @@ import asyncio from contextlib import suppress -import async_timeout from sharkiq import ( AylaApi, SharkIqAuthError, @@ -35,7 +34,7 @@ class CannotConnect(exceptions.HomeAssistantError): async def async_connect_or_timeout(ayla_api: AylaApi) -> bool: """Connect to vacuum.""" try: - async with async_timeout.timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() except SharkIqAuthError: @@ -87,7 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator): """Disconnect to vacuum.""" LOGGER.debug("Disconnecting from Ayla Api") - async with async_timeout.timeout(5): + async with asyncio.timeout(5): with suppress( SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError ): diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 4161a5f5357..1957d12048f 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -6,7 +6,6 @@ from collections.abc import Mapping from typing import Any import aiohttp -import async_timeout from sharkiq import SharkIqAuthError, get_ayla_api import voluptuous as vol @@ -51,7 +50,7 @@ async def _validate_input( ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index 87f5aafe7a4..4cfbb033566 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta -from async_timeout import timeout from sharkiq import ( AylaApi, SharkIqAuthError, @@ -55,7 +54,7 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): """Asynchronously update the data for a single vacuum.""" dsn = sharkiq.serial_number LOGGER.debug("Updating sharkiq data for device DSN %s", dsn) - async with timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): await sharkiq.async_update() async def _async_update_data(self) -> bool: diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index ca24212a96c..8c6c4a9197a 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 8430d7284ee..67258d701e9 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -6,7 +6,6 @@ from contextlib import suppress import logging import shlex -import async_timeout import voluptuous as vol from homeassistant.core import ( @@ -89,7 +88,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: process = await create_process try: - async with async_timeout.timeout(COMMAND_TIMEOUT): + async with asyncio.timeout(COMMAND_TIMEOUT): stdout_data, stderr_data = await process.communicate() except asyncio.TimeoutError: _LOGGER.error( @@ -105,14 +104,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: raise - service_response: JsonObjectType = { - "stdout": "", - "stderr": "", - "returncode": process.returncode, - } - if stdout_data: - service_response["stdout"] = stdout_data.decode("utf-8").strip() _LOGGER.debug( "Stdout of command: `%s`, return code: %s:\n%s", cmd, @@ -120,7 +112,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: stdout_data, ) if stderr_data: - service_response["stderr"] = stderr_data.decode("utf-8").strip() _LOGGER.debug( "Stderr of command: `%s`, return code: %s:\n%s", cmd, @@ -132,7 +123,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "Error running command: `%s`, return code: %s", cmd, process.returncode ) - return service_response + if service.return_response: + service_response: JsonObjectType = { + "stdout": "", + "stderr": "", + "returncode": process.returncode, + } + try: + if stdout_data: + service_response["stdout"] = stdout_data.decode("utf-8").strip() + if stderr_data: + service_response["stderr"] = stderr_data.decode("utf-8").strip() + return service_response + except UnicodeDecodeError: + _LOGGER.exception( + "Unable to handle non-utf8 output of command: `%s`", cmd + ) + raise + return None for name in conf: hass.services.async_register( diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index e5e90bf19af..09d9e3655f0 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -4,10 +4,14 @@ from __future__ import annotations import contextlib from typing import Any, Final -from aioshelly.block_device import BlockDevice +from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.common import ConnectionOptions -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError -from aioshelly.rpc_device import RpcDevice, UpdateType +from aioshelly.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, +) +from aioshelly.rpc_device import RpcDevice, RpcUpdateType import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -168,7 +172,7 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, platforms) @callback - def _async_device_online(_: Any) -> None: + def _async_device_online(_: Any, update_type: BlockUpdateType) -> None: LOGGER.debug("Device %s is online, resuming setup", entry.title) shelly_entry_data.device = None @@ -185,7 +189,7 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b LOGGER.debug("Setting up online block device %s", entry.title) try: await device.initialize() - except DeviceConnectionError as err: + except (DeviceConnectionError, MacAddressMismatchError) as err: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err @@ -253,7 +257,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, platforms) @callback - def _async_device_online(_: Any, update_type: UpdateType) -> None: + def _async_device_online(_: Any, update_type: RpcUpdateType) -> None: LOGGER.debug("Device %s is online, resuming setup", entry.title) shelly_entry_data.device = None @@ -271,7 +275,7 @@ 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 DeviceConnectionError as err: + except (DeviceConnectionError, MacAddressMismatchError) as err: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index ac01033f2c7..edc33c9a8a0 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -14,8 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +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 homeassistant.util import slugify diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 04c211a98cb..a9712e62d25 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -21,8 +21,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import ( RegistryEntry, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index cc82f0ad700..33b4caa5034 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -179,3 +179,5 @@ MAX_PUSH_UPDATE_FAILURES = 5 PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" + +GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 0d4a091b729..d645b09799f 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -9,9 +9,9 @@ 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 +from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError -from aioshelly.rpc_device import RpcDevice, UpdateType +from aioshelly.rpc_device import RpcDevice, RpcUpdateType from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -274,8 +274,23 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): except InvalidAuthError: self.entry.async_start_reauth(self.hass) else: + device_update_info(self.hass, self.device, self.entry) + + @callback + def _async_handle_update( + self, device_: BlockDevice, update_type: BlockUpdateType + ) -> None: + """Handle device update.""" + if update_type == BlockUpdateType.COAP_PERIODIC: + self._push_update_failures = 0 + ir.async_delete_issue( + self.hass, + DOMAIN, + PUSH_UPDATE_ISSUE_ID.format(unique=self.mac), + ) + elif update_type == BlockUpdateType.COAP_REPLY: self._push_update_failures += 1 - if self._push_update_failures > MAX_PUSH_UPDATE_FAILURES: + if self._push_update_failures == MAX_PUSH_UPDATE_FAILURES: LOGGER.debug( "Creating issue %s", PUSH_UPDATE_ISSUE_ID.format(unique=self.mac) ) @@ -293,12 +308,15 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): "ip_address": self.device.ip_address, }, ) - device_update_info(self.hass, self.device, self.entry) + LOGGER.debug( + "Push update failures for %s: %s", self.name, self._push_update_failures + ) + self.async_set_updated_data(None) def async_setup(self) -> None: """Set up the coordinator.""" super().async_setup() - self.device.subscribe_updates(self.async_set_updated_data) + self.device.subscribe_updates(self._async_handle_update) def shutdown(self) -> None: """Shutdown the coordinator.""" @@ -535,16 +553,18 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) @callback - def _async_handle_update(self, device_: RpcDevice, update_type: UpdateType) -> None: + def _async_handle_update( + self, device_: RpcDevice, update_type: RpcUpdateType + ) -> None: """Handle device update.""" - if update_type is UpdateType.INITIALIZED: + if update_type is RpcUpdateType.INITIALIZED: self.hass.async_create_task(self._async_connected()) self.async_set_updated_data(None) - elif update_type is UpdateType.DISCONNECTED: + elif update_type is RpcUpdateType.DISCONNECTED: self.hass.async_create_task(self._async_disconnected()) - elif update_type is UpdateType.STATUS: + elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) - elif update_type is UpdateType.EVENT and (event := self.device.event): + elif update_type is RpcUpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) def async_setup(self) -> None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 548428c444c..1dc7573b738 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -11,8 +11,8 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCal from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import ( RegistryEntry, diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 6031b2dcc82..c76e2102fa1 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==5.4.0"], + "requirements": ["aioshelly==6.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index d1e05e5b829..abcca888005 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -97,6 +97,14 @@ SENSORS: Final = { device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), + ("device", "neutralCurrent"): BlockSensorDescription( + key="device|neutralCurrent", + name="Neutral current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ("light", "power"): BlockSensorDescription( key="light|power", name="Power", @@ -304,6 +312,24 @@ SENSORS: Final = { value=lambda value: value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), + ("valve", "valve"): BlockSensorDescription( + key="valve|valve", + name="Valve status", + translation_key="valve_status", + icon="mdi:valve", + device_class=SensorDeviceClass.ENUM, + options=[ + "checking", + "closed", + "closing", + "failure", + "opened", + "opening", + "unknown", + ], + entity_category=EntityCategory.DIAGNOSTIC, + removal_condition=lambda _, block: block.valve == "not_connected", + ), } REST_SENSORS: Final = { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 6ff48f5b85b..043ff419742 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -116,6 +116,17 @@ } } } + }, + "valve_status": { + "state": { + "checking": "Checking", + "closed": "Closed", + "closing": "Closing", + "failure": "Failure", + "opened": "Opened", + "opening": "Opening", + "unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]" + } } } }, diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 3f5186a2017..395b386993a 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -1,17 +1,25 @@ """Switch for Shelly.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import GAS_VALVE_OPEN_STATES from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data -from .entity import ShellyBlockEntity, ShellyRpcEntity +from .entity import ( + BlockEntityDescription, + ShellyBlockAttributeEntity, + ShellyBlockEntity, + ShellyRpcEntity, + async_setup_block_attribute_entities, +) from .utils import ( async_remove_shelly_entity, get_device_entry_gen, @@ -21,6 +29,19 @@ from .utils import ( ) +@dataclass +class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): + """Class to describe a BLOCK switch.""" + + +GAS_VALVE_SWITCH = BlockSwitchDescription( + key="valve|valve", + name="Valve", + available=lambda block: block.valve not in ("failure", "checking"), + removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -43,6 +64,17 @@ def async_setup_block_entry( coordinator = get_entry_data(hass)[config_entry.entry_id].block assert coordinator + # Add Shelly Gas Valve as a switch + if coordinator.model == "SHGS-1": + async_setup_block_attribute_entities( + hass, + async_add_entities, + coordinator, + {("valve", "valve"): GAS_VALVE_SWITCH}, + BlockValveSwitch, + ) + return + # In roller mode the relay blocks exist but do not contain required info if ( coordinator.model in ["SHSW-21", "SHSW-25"] @@ -94,6 +126,53 @@ def async_setup_rpc_entry( async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) +class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): + """Entity that controls a Gas Valve on Block based Shelly devices.""" + + entity_description: BlockSwitchDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockSwitchDescription, + ) -> None: + """Initialize valve.""" + super().__init__(coordinator, block, attribute, description) + self.control_result: dict[str, Any] | None = None + + @property + def is_on(self) -> bool: + """If valve is open.""" + if self.control_result: + return self.control_result["state"] in GAS_VALVE_OPEN_STATES + + return self.attribute_value in GAS_VALVE_OPEN_STATES + + @property + def icon(self) -> str: + """Return the icon.""" + return "mdi:valve-open" if self.is_on else "mdi:valve-closed" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Open valve.""" + self.control_result = await self.set_state(go="open") + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Close valve.""" + self.control_result = await self.set_state(go="close") + self.async_write_ha_state() + + @callback + def _update_callback(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + + super()._update_callback() + + class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): """Entity that controls a relay on Block based Shelly devices.""" diff --git a/homeassistant/components/shopping_list/config_flow.py b/homeassistant/components/shopping_list/config_flow.py index 23f66cecebb..0637dcea390 100644 --- a/homeassistant/components/shopping_list/config_flow.py +++ b/homeassistant/components/shopping_list/config_flow.py @@ -1,23 +1,36 @@ -"""Config flow to configure ShoppingList component.""" -from homeassistant import config_entries +"""Config flow to configure the shopping list integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN -class ShoppingListFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Config flow for ShoppingList component.""" +class ShoppingListFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for the shopping list integration.""" 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.""" # Check if already configured await self.async_set_unique_id(DOMAIN) self._abort_if_unique_id_configured() if user_input is not None: - return self.async_create_entry(title="Shopping List", data=user_input) + return self.async_create_entry(title="Shopping list", data={}) return self.async_show_form(step_id="user") async_step_import = async_step_user + + async def async_step_onboarding( + self, _: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by onboarding.""" + return await self.async_step_user(user_input={}) diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 64ca3832ce0..859841d3bea 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -110,14 +110,12 @@ class SIAHub: self.sia_client.accounts = self.sia_accounts return # the new client class method creates a subclass based on protocol, hence the type ignore - self.sia_client = ( - SIAClient( # pylint: disable=abstract-class-instantiated # type: ignore - host="", - port=self._port, - accounts=self.sia_accounts, - function=self.async_create_and_fire_event, - protocol=CommunicationsProtocol(self._protocol), - ) + self.sia_client = SIAClient( + host="", + port=self._port, + accounts=self.sia_accounts, + function=self.async_create_and_fire_event, + protocol=CommunicationsProtocol(self._protocol), ) def _load_options(self) -> None: diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index 7ca48bdc46e..a947f9e177b 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -10,8 +10,9 @@ from pysiaalarm import SIAEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import CALLBACK_TYPE, State, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index dec1b35d346..7b57fa1fc32 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -66,11 +66,11 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.service import ( async_register_admin_service, verify_domain_control, diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index d137824b3db..d0d2a4c5689 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.05.0"] + "requirements": ["simplisafe-python==2023.08.0"] } diff --git a/homeassistant/components/skybell/entity.py b/homeassistant/components/skybell/entity.py index 29c7167b02b..2d596ec8aac 100644 --- a/homeassistant/components/skybell/entity.py +++ b/homeassistant/components/skybell/entity.py @@ -5,7 +5,8 @@ from aioskybell import SkybellDevice from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index 076dcf7e590..47ee07a7004 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -12,8 +12,8 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import ConfigType from .const import ( diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index e6eeaa98c22..38d8eb32051 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -6,7 +6,8 @@ from asyncsleepiq import SleepIQBed, SleepIQSleeper from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ENTITY_TYPES, ICON_OCCUPIED diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 3d757e2328d..874ae90ec4a 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.3.5"] + "requirements": ["asyncsleepiq==1.3.7"] } diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 9bd9f7668c8..1bf3c57fee2 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -20,7 +20,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 13f402b53c3..419fd6aa8ed 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 2987d2648c2..dbcc1931e58 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +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 ( diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index 88d46e3689d..71bbaa472ae 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index d39f173d76b..4228f57ea46 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 828e4a68121..1928e717f22 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 6606352ffc8..22856bdb05b 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -18,11 +18,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType @@ -57,7 +58,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the SmartThings platform.""" - await setup_smartapp_endpoint(hass) + await setup_smartapp_endpoint(hass, False) return True diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index 0328c3a7f8e..5e3451dfbce 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -50,6 +50,7 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.installed_app_id = None self.refresh_token = None self.location_id = None + self.endpoints_initialized = False async def async_step_import(self, user_input=None): """Occurs when a previously entry setup fails and is re-initiated.""" @@ -57,7 +58,11 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Validate and confirm webhook setup.""" - await setup_smartapp_endpoint(self.hass) + if not self.endpoints_initialized: + self.endpoints_initialized = True + await setup_smartapp_endpoint( + self.hass, len(self._async_current_entries()) == 0 + ) webhook_url = get_webhook_url(self.hass) # Abort if the webhook is invalid diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 9b17034ab3b..78c0bfa86b1 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -197,7 +197,7 @@ def setup_smartapp(hass, app): return smartapp -async def setup_smartapp_endpoint(hass: HomeAssistant): +async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool): """Configure the SmartApp webhook in hass. SmartApps are an extension point within the SmartThings ecosystem and @@ -205,11 +205,16 @@ async def setup_smartapp_endpoint(hass: HomeAssistant): """ if hass.data.get(DOMAIN): # already setup - return + if not fresh_install: + return + + # We're doing a fresh install, clean up + await unload_smartapp_endpoint(hass) # Get/create config to store a unique id for this hass instance. store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY) - if not (config := await store.async_load()): + + if fresh_install or not (config := await store.async_load()): # Create config config = { CONF_INSTANCE_ID: str(uuid4()), diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 5d68b90145f..72157e086e3 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -5,7 +5,6 @@ from datetime import timedelta import logging from aiohttp import client_exceptions -import async_timeout from smarttub import APIError, LoginFailed, SmartTub from smarttub.api import Account @@ -85,7 +84,7 @@ class SmartTubController: data = {} try: - async with async_timeout.timeout(POLLING_TIMEOUT): + async with asyncio.timeout(POLLING_TIMEOUT): for spa in self.spas: data[spa.id] = await self._get_spa_data(spa) except APIError as err: diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 0c935d77b5d..7f2a739c26e 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -1,7 +1,7 @@ """Base classes for SmartTub entities.""" import smarttub -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index d01b92c2186..e105963bc01 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -1,7 +1,7 @@ """Platform for switch integration.""" +import asyncio from typing import Any -import async_timeout from smarttub import SpaPump from homeassistant.components.switch import SwitchEntity @@ -80,6 +80,6 @@ class SmartTubPump(SmartTubEntity, SwitchEntity): async def async_toggle(self, **kwargs: Any) -> None: """Toggle the pump on or off.""" - async with async_timeout.timeout(API_TIMEOUT): + async with asyncio.timeout(API_TIMEOUT): await self.pump.toggle() await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 036fb6e1e90..e3cf1dcf287 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -3,7 +3,7 @@ from datetime import timedelta import ipaddress import logging -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, Platform diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index baa25115186..d9d757a71b5 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf7db560c15..cf4b49e6105 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -5,7 +5,7 @@ import logging import math from typing import Any -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index df99529b1f4..57d681594cf 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime as dt import logging -from pysmarty import Smarty # pylint: disable=import-error +from pysmarty import Smarty from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index ece0e4f6d5c..05683f19b11 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -8,7 +8,6 @@ import logging from typing import Any, Final import aiohttp -import async_timeout from smhi import Smhi from smhi.smhi_lib import SmhiForecast, SmhiForecastException @@ -40,6 +39,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_BEARING, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -55,17 +55,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.util import Throttle, slugify -from .const import ( - ATTR_SMHI_THUNDER_PROBABILITY, - DOMAIN, - ENTITY_ID_SENSOR_FORMAT, -) +from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT _LOGGER = logging.getLogger(__name__) @@ -86,6 +81,11 @@ CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } +CONDITION_MAP = { + cond_code: cond_ha + for cond_ha, cond_codes in CONDITION_CLASSES.items() + for cond_code in cond_codes +} TIMEOUT = 10 # 5 minutes between retrying connect to API again @@ -128,6 +128,9 @@ class SmhiWeather(WeatherEntity): _attr_has_entity_name = True _attr_name = None + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__( self, @@ -138,7 +141,8 @@ class SmhiWeather(WeatherEntity): ) -> None: """Initialize the SMHI weather entity.""" self._attr_unique_id = f"{latitude}, {longitude}" - self._forecasts: list[SmhiForecast] | None = None + self._forecast_daily: list[SmhiForecast] | None = None + self._forecast_hourly: list[SmhiForecast] | None = None self._fail_count = 0 self._smhi_api = Smhi(longitude, latitude, session=session) self._attr_device_info = DeviceInfo( @@ -149,15 +153,13 @@ class SmhiWeather(WeatherEntity): name=name, configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) - self._attr_condition = None - self._attr_native_temperature = None @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional attributes.""" - if self._forecasts: + if self._forecast_daily: return { - ATTR_SMHI_THUNDER_PROBABILITY: self._forecasts[0].thunder, + ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0].thunder, } return None @@ -165,8 +167,9 @@ class SmhiWeather(WeatherEntity): async def async_update(self) -> None: """Refresh the forecast data from SMHI weather API.""" try: - async with async_timeout.timeout(TIMEOUT): - self._forecasts = await self._smhi_api.async_get_forecast() + async with asyncio.timeout(TIMEOUT): + self._forecast_daily = await self._smhi_api.async_get_forecast() + self._forecast_hourly = await self._smhi_api.async_get_forecast_hour() self._fail_count = 0 except (asyncio.TimeoutError, SmhiForecastException): _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") @@ -175,42 +178,32 @@ class SmhiWeather(WeatherEntity): async_call_later(self.hass, RETRY_TIMEOUT, self.retry_update) return - if self._forecasts: - self._attr_native_temperature = self._forecasts[0].temperature - self._attr_humidity = self._forecasts[0].humidity - self._attr_native_wind_speed = self._forecasts[0].wind_speed - self._attr_wind_bearing = self._forecasts[0].wind_direction - self._attr_native_visibility = self._forecasts[0].horizontal_visibility - self._attr_native_pressure = self._forecasts[0].pressure - self._attr_native_wind_gust_speed = self._forecasts[0].wind_gust - self._attr_cloud_coverage = self._forecasts[0].cloudiness - self._attr_condition = next( - ( - k - for k, v in CONDITION_CLASSES.items() - if self._forecasts[0].symbol in v - ), - None, - ) + if self._forecast_daily: + self._attr_native_temperature = self._forecast_daily[0].temperature + self._attr_humidity = self._forecast_daily[0].humidity + self._attr_native_wind_speed = self._forecast_daily[0].wind_speed + self._attr_wind_bearing = self._forecast_daily[0].wind_direction + self._attr_native_visibility = self._forecast_daily[0].horizontal_visibility + self._attr_native_pressure = self._forecast_daily[0].pressure + self._attr_native_wind_gust_speed = self._forecast_daily[0].wind_gust + self._attr_cloud_coverage = self._forecast_daily[0].cloudiness + self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0].symbol) + await self.async_update_listeners(("daily", "hourly")) async def retry_update(self, _: datetime) -> None: """Retry refresh weather forecast.""" - await self.async_update( # pylint: disable=unexpected-keyword-arg - no_throttle=True - ) + await self.async_update(no_throttle=True) @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" - if self._forecasts is None or len(self._forecasts) < 2: + if self._forecast_daily is None or len(self._forecast_daily) < 2: return None data: list[Forecast] = [] - for forecast in self._forecasts[1:]: - condition = next( - (k for k, v in CONDITION_CLASSES.items() if forecast.symbol in v), None - ) + for forecast in self._forecast_daily[1:]: + condition = CONDITION_MAP.get(forecast.symbol) data.append( { @@ -229,3 +222,41 @@ class SmhiWeather(WeatherEntity): ) return data + + def _get_forecast_data( + self, forecast_data: list[SmhiForecast] | None + ) -> list[Forecast] | None: + """Get forecast data.""" + if forecast_data is None or len(forecast_data) < 3: + return None + + data: list[Forecast] = [] + + for forecast in forecast_data[1:]: + condition = CONDITION_MAP.get(forecast.symbol) + + data.append( + { + ATTR_FORECAST_TIME: forecast.valid_time.isoformat(), + ATTR_FORECAST_NATIVE_TEMP: forecast.temperature_max, + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast.temperature_min, + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.total_precipitation, + ATTR_FORECAST_CONDITION: condition, + ATTR_FORECAST_NATIVE_PRESSURE: forecast.pressure, + ATTR_FORECAST_WIND_BEARING: forecast.wind_direction, + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast.wind_speed, + ATTR_FORECAST_HUMIDITY: forecast.humidity, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: forecast.wind_gust, + ATTR_FORECAST_CLOUD_COVERAGE: forecast.cloudiness, + } + ) + + return data + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Service to retrieve the daily forecast.""" + return self._get_forecast_data(self._forecast_daily) + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Service to retrieve the hourly forecast.""" + return self._get_forecast_data(self._forecast_hourly) diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 27cb7ac034d..824a95e36b1 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -1,9 +1,9 @@ """The sms component.""" +import asyncio from datetime import timedelta import logging -import async_timeout -import gammu # pylint: disable=import-error +import gammu import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -125,7 +125,7 @@ class SignalCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Fetch device signal quality.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._gateway.get_signal_quality_async() except gammu.GSMError as exc: raise UpdateFailed(f"Error communicating with device: {exc}") from exc @@ -147,7 +147,7 @@ class NetworkCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Fetch device network info.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self._gateway.get_network_info_async() except gammu.GSMError as exc: raise UpdateFailed(f"Error communicating with device: {exc}") from exc diff --git a/homeassistant/components/sms/config_flow.py b/homeassistant/components/sms/config_flow.py index 9128b6187c1..df3530764cb 100644 --- a/homeassistant/components/sms/config_flow.py +++ b/homeassistant/components/sms/config_flow.py @@ -1,7 +1,7 @@ """Config flow for SMS integration.""" import logging -import gammu # pylint: disable=import-error +import gammu import voluptuous as vol from homeassistant import config_entries, core, exceptions diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 36ada5421e0..578b2191bd2 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -1,8 +1,8 @@ """The sms gateway to interact with a GSM modem.""" import logging -import gammu # pylint: disable=import-error -from gammu.asyncworker import GammuAsyncWorker # pylint: disable=import-error +import gammu +from gammu.asyncworker import GammuAsyncWorker from homeassistant.core import callback diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py index 9d94472b1b8..21d3ab2beb5 100644 --- a/homeassistant/components/sms/notify.py +++ b/homeassistant/components/sms/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -import gammu # pylint: disable=import-error +import gammu from homeassistant.components.notify import ATTR_DATA, BaseNotificationService from homeassistant.const import CONF_TARGET diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index 0ad727faf2c..d4c45b83d82 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 28c3121a172..7037c239db3 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -56,7 +56,6 @@ DEFAULT_ENCRYPTION = "starttls" ENCRYPTION_OPTIONS = ["tls", "starttls", "none"] -# pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]), diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index fc8068fb532..a5915183ad0 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -19,11 +19,15 @@ from pysnmp.hlapi.asyncio import ( ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor import CONF_STATE_CLASS, PLATFORM_SCHEMA from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_HOST, + CONF_ICON, + CONF_NAME, CONF_PORT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, @@ -31,9 +35,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.template import Template +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, + ManualTriggerSensorEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -64,6 +71,16 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_BASEOID): cv.string, @@ -106,7 +123,6 @@ async def async_setup_platform( privproto = config[CONF_PRIV_PROTOCOL] accept_errors = config.get(CONF_ACCEPT_ERRORS) default_value = config.get(CONF_DEFAULT_VALUE) - unique_id = config.get(CONF_UNIQUE_ID) try: # Try IPv4 first. @@ -151,35 +167,50 @@ async def async_setup_platform( _LOGGER.error("Please check the details in the configuration file") return + name = config.get(CONF_NAME, Template(DEFAULT_NAME, hass)) + trigger_entity_config = {CONF_NAME: name} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in config: + continue + trigger_entity_config[key] = config[key] + + value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = hass + data = SnmpData(request_args, baseoid, accept_errors, default_value) - async_add_entities([SnmpSensor(hass, data, config, unique_id)], True) + async_add_entities([SnmpSensor(hass, data, trigger_entity_config, value_template)]) -class SnmpSensor(TemplateSensor): +class SnmpSensor(ManualTriggerSensorEntity): """Representation of a SNMP sensor.""" _attr_should_poll = True - def __init__(self, hass, data, config, unique_id): + def __init__( + self, + hass: HomeAssistant, + data: SnmpData, + config: ConfigType, + value_template: Template | None, + ) -> None: """Initialize the sensor.""" - super().__init__( - hass, config=config, unique_id=unique_id, fallback_name=DEFAULT_NAME - ) + super().__init__(hass, config) self.data = data self._state = None - self._value_template = config.get(CONF_VALUE_TEMPLATE) - if (value_template := self._value_template) is not None: - value_template.hass = hass + self._value_template = value_template - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state + async def async_added_to_hass(self) -> None: + """Handle adding to Home Assistant.""" + await super().async_added_to_hass() + await self.async_update() async def async_update(self) -> None: """Get the latest data and updates the states.""" await self.data.async_update() + raw_value = self.data.value + if (value := self.data.value) is None: value = STATE_UNKNOWN elif self._value_template is not None: @@ -187,13 +218,14 @@ class SnmpSensor(TemplateSensor): value, STATE_UNKNOWN ) - self._state = value + self._attr_native_value = value + self._process_manual_data(raw_value) class SnmpData: """Get the latest data and update the states.""" - def __init__(self, request_args, baseoid, accept_errors, default_value): + def __init__(self, request_args, baseoid, accept_errors, default_value) -> None: """Initialize the data object.""" self._request_args = request_args self._baseoid = baseoid diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index a34989d1a03..c5b3e5b5b69 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -21,6 +21,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -71,15 +72,18 @@ async def async_setup_entry( class SnoozFan(FanEntity, RestoreEntity): """Fan representation of a Snooz device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, data: SnoozConfigurationData) -> None: """Initialize a Snooz fan entity.""" self._device = data.device - self._attr_name = data.title self._attr_unique_id = data.device.address self._attr_supported_features = FanEntityFeature.SET_SPEED self._attr_should_poll = False self._is_on: bool | None = None self._percentage: int | None = None + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, data.device.address)}) @callback def _async_write_state_changed(self) -> None: diff --git a/homeassistant/components/snooz/manifest.json b/homeassistant/components/snooz/manifest.json index cd132d5a175..5b43aa7e92d 100644 --- a/homeassistant/components/snooz/manifest.json +++ b/homeassistant/components/snooz/manifest.json @@ -14,5 +14,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/snooz", "iot_class": "local_push", - "requirements": ["pysnooz==0.8.3"] + "requirements": ["pysnooz==0.8.6"] } diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 6d95f8b6aec..aa6251ff433 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -2,11 +2,6 @@ from datetime import timedelta import logging -from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass -from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower - -from .models import SolarEdgeSensorEntityDescription - DOMAIN = "solaredge" LOGGER = logging.getLogger(__package__) @@ -24,170 +19,3 @@ POWER_FLOW_UPDATE_DELAY = timedelta(minutes=15) ENERGY_DETAILS_DELAY = timedelta(minutes=15) SCAN_INTERVAL = timedelta(minutes=15) - - -# Supported overview sensors -SENSOR_TYPES = [ - SolarEdgeSensorEntityDescription( - key="lifetime_energy", - json_key="lifeTimeData", - name="Lifetime energy", - icon="mdi:solar-power", - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="energy_this_year", - json_key="lastYearData", - name="Energy this year", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="energy_this_month", - json_key="lastMonthData", - name="Energy this month", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="energy_today", - json_key="lastDayData", - name="Energy today", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="current_power", - json_key="currentPower", - name="Current Power", - icon="mdi:solar-power", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - ), - SolarEdgeSensorEntityDescription( - key="site_details", - json_key="status", - name="Site details", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="meters", - json_key="meters", - name="Meters", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="sensors", - json_key="sensors", - name="Sensors", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="gateways", - json_key="gateways", - name="Gateways", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="batteries", - json_key="batteries", - name="Batteries", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="inverters", - json_key="inverters", - name="Inverters", - entity_registry_enabled_default=False, - ), - SolarEdgeSensorEntityDescription( - key="power_consumption", - json_key="LOAD", - name="Power Consumption", - entity_registry_enabled_default=False, - icon="mdi:flash", - ), - SolarEdgeSensorEntityDescription( - key="solar_power", - json_key="PV", - name="Solar Power", - entity_registry_enabled_default=False, - icon="mdi:solar-power", - ), - SolarEdgeSensorEntityDescription( - key="grid_power", - json_key="GRID", - name="Grid Power", - entity_registry_enabled_default=False, - icon="mdi:power-plug", - ), - SolarEdgeSensorEntityDescription( - key="storage_power", - json_key="STORAGE", - name="Storage Power", - entity_registry_enabled_default=False, - icon="mdi:car-battery", - ), - SolarEdgeSensorEntityDescription( - key="purchased_energy", - json_key="Purchased", - name="Imported Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="production_energy", - json_key="Production", - name="Production Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="consumption_energy", - json_key="Consumption", - name="Consumption Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="selfconsumption_energy", - json_key="SelfConsumption", - name="SelfConsumption Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="feedin_energy", - json_key="FeedIn", - name="Exported Energy", - entity_registry_enabled_default=False, - state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SolarEdgeSensorEntityDescription( - key="storage_level", - json_key="STORAGE", - name="Storage Level", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - ), -] diff --git a/homeassistant/components/solaredge/models.py b/homeassistant/components/solaredge/models.py deleted file mode 100644 index 57efb88023c..00000000000 --- a/homeassistant/components/solaredge/models.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Models for the SolarEdge integration.""" -from __future__ import annotations - -from dataclasses import dataclass - -from homeassistant.components.sensor import SensorEntityDescription - - -@dataclass -class SolarEdgeSensorEntityRequiredKeyMixin: - """Sensor entity description with json_key for SolarEdge.""" - - json_key: str - - -@dataclass -class SolarEdgeSensorEntityDescription( - SensorEntityDescription, SolarEdgeSensorEntityRequiredKeyMixin -): - """Sensor entity description for SolarEdge.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 3a4b5ad90c2..e1ea7960086 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -1,12 +1,19 @@ """Support for SolarEdge Monitoring API.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any from solaredge import Solaredge -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -14,7 +21,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN, SENSOR_TYPES +from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN from .coordinator import ( SolarEdgeDataService, SolarEdgeDetailsDataService, @@ -23,7 +30,186 @@ from .coordinator import ( SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, ) -from .models import SolarEdgeSensorEntityDescription + + +@dataclass +class SolarEdgeSensorEntityRequiredKeyMixin: + """Sensor entity description with json_key for SolarEdge.""" + + json_key: str + + +@dataclass +class SolarEdgeSensorEntityDescription( + SensorEntityDescription, SolarEdgeSensorEntityRequiredKeyMixin +): + """Sensor entity description for SolarEdge.""" + + +SENSOR_TYPES = [ + SolarEdgeSensorEntityDescription( + key="lifetime_energy", + json_key="lifeTimeData", + name="Lifetime energy", + icon="mdi:solar-power", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="energy_this_year", + json_key="lastYearData", + name="Energy this year", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="energy_this_month", + json_key="lastMonthData", + name="Energy this month", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="energy_today", + json_key="lastDayData", + name="Energy today", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="current_power", + json_key="currentPower", + name="Current Power", + icon="mdi:solar-power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), + SolarEdgeSensorEntityDescription( + key="site_details", + json_key="status", + name="Site details", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="meters", + json_key="meters", + name="Meters", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="sensors", + json_key="sensors", + name="Sensors", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="gateways", + json_key="gateways", + name="Gateways", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="batteries", + json_key="batteries", + name="Batteries", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="inverters", + json_key="inverters", + name="Inverters", + entity_registry_enabled_default=False, + ), + SolarEdgeSensorEntityDescription( + key="power_consumption", + json_key="LOAD", + name="Power Consumption", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + SolarEdgeSensorEntityDescription( + key="solar_power", + json_key="PV", + name="Solar Power", + entity_registry_enabled_default=False, + icon="mdi:solar-power", + ), + SolarEdgeSensorEntityDescription( + key="grid_power", + json_key="GRID", + name="Grid Power", + entity_registry_enabled_default=False, + icon="mdi:power-plug", + ), + SolarEdgeSensorEntityDescription( + key="storage_power", + json_key="STORAGE", + name="Storage Power", + entity_registry_enabled_default=False, + icon="mdi:car-battery", + ), + SolarEdgeSensorEntityDescription( + key="purchased_energy", + json_key="Purchased", + name="Imported Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="production_energy", + json_key="Production", + name="Production Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="consumption_energy", + json_key="Consumption", + name="Consumption Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="selfconsumption_energy", + json_key="SelfConsumption", + name="SelfConsumption Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="feedin_energy", + json_key="FeedIn", + name="Exported Energy", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SolarEdgeSensorEntityDescription( + key="storage_level", + json_key="STORAGE", + name="Storage Level", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), +] async def async_setup_entry( diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 936dc998c86..cd8304a1198 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import as_local diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index fd0db1be054..eee74c1007f 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index a929bd24b25..aa948703118 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -10,7 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import API, DOMAIN, HOST, PORT diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 6472f6934e0..d1c0de188a0 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -43,11 +43,12 @@ class SomaSensor(SomaEntity, SensorEntity): async def async_update(self) -> None: """Update the sensor with the latest data.""" response = await self.get_battery_level_from_api() - - # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API - # battery_level response is expected to be min = 360, max 410 for - # 0-100% levels above 410 are consider 100% and below 360, 0% as the - # device considers 360 the minimum to move the motor. - _battery = round(2 * (response["battery_level"] - 360)) + _battery = response.get("battery_percentage") + if _battery is None: + # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API + # battery_level response is expected to be min = 360, max 410 for + # 0-100% levels above 410 are consider 100% and below 360, 0% as the + # device considers 360 the minimum to move the motor. + _battery = round(2 * (response["battery_level"] - 360)) battery = max(min(100, _battery), 0) self.battery_state = battery diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index 43c9ca63bb5..38f5fdc12f8 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -6,7 +6,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -65,6 +65,8 @@ class SomfyShade(RestoreEntity, CoverEntity): _attr_should_poll = False _attr_assumed_state = True + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -78,7 +80,6 @@ class SomfyShade(RestoreEntity, CoverEntity): self.somfy_mylink = somfy_mylink self._target_id = target_id self._attr_unique_id = target_id - self._attr_name = name self._reverse = reverse self._attr_is_closed = None self._attr_device_class = device_class diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index e8a65239be7..d73b9d852c8 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -1,8 +1,8 @@ """Base Entity for Sonarr.""" from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index bc5e15ba989..2d2c5892636 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -5,7 +5,6 @@ import asyncio from collections import OrderedDict import logging -import async_timeout from songpal import ( ConnectChange, ContentChange, @@ -30,7 +29,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_platform, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -68,7 +67,7 @@ async def async_setup_entry( device = Device(endpoint) try: - async with async_timeout.timeout( + async with asyncio.timeout( 10 ): # set timeout to avoid blocking the setup process await device.get_supported_methods() diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 0b51687a465..90cadcdad37 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -8,8 +8,9 @@ import logging from soco.core import SoCo import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED from .exception import SonosUpdateError diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index e576d3f7908..b73ca6a77e4 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -10,7 +10,6 @@ import logging import time from typing import Any, cast -import async_timeout import defusedxml.ElementTree as ET from soco.core import SoCo from soco.events_base import Event as SonosEvent, SubscriptionBase @@ -1122,7 +1121,7 @@ class SonosSpeaker: return True try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): while not _test_groups(groups): await hass.data[DATA_SONOS].topology_condition.wait() except asyncio.TimeoutError: diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index f8670074c5c..63e5a551745 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -22,8 +22,11 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/spc/manifest.json b/homeassistant/components/spc/manifest.json index 82f6ed62029..a707e1a7804 100644 --- a/homeassistant/components/spc/manifest.json +++ b/homeassistant/components/spc/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/spc", "iot_class": "local_push", "loggers": ["pyspcwebgw"], - "requirements": ["pyspcwebgw==0.4.0"] + "requirements": ["pyspcwebgw==0.7.0"] } diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index a5ccb78baed..5bcf178f396 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 2769d045c0b..1498c4b0039 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -9,7 +9,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py index 5b326db1e45..bce437437c6 100644 --- a/homeassistant/components/spider/sensor.py +++ b/homeassistant/components/spider/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py index 28bbf0fcc18..508dcee9d73 100644 --- a/homeassistant/components/spider/switch.py +++ b/homeassistant/components/spider/switch.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 41d27b68672..d05e4282edf 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -25,8 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 316e816fd6f..4658e19932c 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -24,7 +24,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, +) from homeassistant.helpers.typing import ConfigType from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index aecc34d7009..3fdc6b2c079 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -19,12 +19,7 @@ from homeassistant.components.recorder import ( SupportedDialect, get_instance, ) -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -38,14 +33,13 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( +from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, - ManualTriggerEntity, + ManualTriggerSensorEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -57,6 +51,16 @@ _LOGGER = logging.getLogger(__name__) _SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) +TRIGGER_ENTITY_OPTIONS = ( + CONF_AVAILABILITY, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_PICTURE, + CONF_UNIQUE_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) + async def async_setup_platform( hass: HomeAssistant, @@ -70,43 +74,29 @@ async def async_setup_platform( name: Template = conf[CONF_NAME] query_str: str = conf[CONF_QUERY] - unit: str | None = conf.get(CONF_UNIT_OF_MEASUREMENT) value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) - device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) - state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) - availability: Template | None = conf.get(CONF_AVAILABILITY) - icon: Template | None = conf.get(CONF_ICON) - picture: Template | None = conf.get(CONF_PICTURE) if value_template is not None: value_template.hass = hass - trigger_entity_config = { - CONF_NAME: name, - CONF_DEVICE_CLASS: device_class, - CONF_UNIQUE_ID: unique_id, - } - if availability: - trigger_entity_config[CONF_AVAILABILITY] = availability - if icon: - trigger_entity_config[CONF_ICON] = icon - if picture: - trigger_entity_config[CONF_PICTURE] = picture + trigger_entity_config = {CONF_NAME: name} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in conf: + continue + trigger_entity_config[key] = conf[key] await async_setup_sensor( hass, trigger_entity_config, query_str, column_name, - unit, value_template, unique_id, db_url, True, - state_class, async_add_entities, ) @@ -119,11 +109,8 @@ async def async_setup_entry( db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL)) name: str = entry.options[CONF_NAME] query_str: str = entry.options[CONF_QUERY] - unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] - device_class: SensorDeviceClass | None = entry.options.get(CONF_DEVICE_CLASS, None) - state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS, None) value_template: Template | None = None if template is not None: @@ -136,23 +123,21 @@ async def async_setup_entry( value_template.hass = hass name_template = Template(name, hass) - trigger_entity_config = { - CONF_NAME: name_template, - CONF_DEVICE_CLASS: device_class, - CONF_UNIQUE_ID: entry.entry_id, - } + trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in entry.options: + continue + trigger_entity_config[key] = entry.options[key] await async_setup_sensor( hass, trigger_entity_config, query_str, column_name, - unit, value_template, entry.entry_id, db_url, False, - state_class, async_add_entities, ) @@ -192,12 +177,10 @@ async def async_setup_sensor( trigger_entity_config: ConfigType, query_str: str, column_name: str, - unit: str | None, value_template: Template | None, unique_id: str | None, db_url: str, yaml: bool, - state_class: SensorStateClass | None, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" @@ -275,10 +258,8 @@ async def async_setup_sensor( sessmaker, query_str, column_name, - unit, value_template, yaml, - state_class, use_database_executor, ) ], @@ -318,7 +299,7 @@ def _generate_lambda_stmt(query: str) -> StatementLambdaElement: return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) -class SQLSensor(ManualTriggerEntity, SensorEntity): +class SQLSensor(ManualTriggerSensorEntity): """Representation of an SQL sensor.""" def __init__( @@ -327,30 +308,25 @@ class SQLSensor(ManualTriggerEntity, SensorEntity): sessmaker: scoped_session, query: str, column: str, - unit: str | None, value_template: Template | None, yaml: bool, - state_class: SensorStateClass | None, use_database_executor: bool, ) -> None: """Initialize the SQL sensor.""" super().__init__(self.hass, trigger_entity_config) self._query = query - self._attr_native_unit_of_measurement = unit - self._attr_state_class = state_class self._template = value_template self._column_name = column self.sessionmaker = sessmaker self._attr_extra_state_attributes = {} self._use_database_executor = use_database_executor self._lambda_stmt = _generate_lambda_stmt(query) - if not yaml: + if not yaml and (unique_id := trigger_entity_config.get(CONF_UNIQUE_ID)): self._attr_name = None self._attr_has_entity_name = True - if not yaml and trigger_entity_config.get(CONF_UNIQUE_ID): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, trigger_entity_config[CONF_UNIQUE_ID])}, + identifiers={(DOMAIN, unique_id)}, manufacturer="SQL", name=self.name, ) diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index bb175ee00be..2c96046b97c 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -4,7 +4,6 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING -import async_timeout from pysqueezebox import Server, async_discover import voluptuous as vol @@ -131,7 +130,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # no host specified, see if we can discover an unconfigured LMS server try: - async with async_timeout.timeout(TIMEOUT): + async with asyncio.timeout(TIMEOUT): await self._discover() return await self.async_step_edit() except asyncio.TimeoutError: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index d57ba8ba49d..c77126e4377 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -35,7 +35,7 @@ from homeassistant.helpers import ( entity_platform, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -236,6 +236,8 @@ class SqueezeBoxEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.MEDIA_ENQUEUE ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, player): """Initialize the SqueezeBox device.""" @@ -244,6 +246,10 @@ class SqueezeBoxEntity(MediaPlayerEntity): self._query_result = {} self._available = True self._remove_dispatcher = None + self._attr_unique_id = format_mac(self._player.player_id) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, name=self._player.name + ) @property def extra_state_attributes(self): @@ -256,16 +262,6 @@ class SqueezeBoxEntity(MediaPlayerEntity): return squeezebox_attr - @property - def name(self): - """Return the name of the device.""" - return self._player.name - - @property - def unique_id(self): - """Return a unique ID.""" - return format_mac(self._player.player_id) - @property def available(self): """Return True if device connected to LMS server.""" diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index ea80a29d990..98d1cdd421a 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -5,7 +5,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN, LOGGER +from .const import CONF_IS_TOU, DOMAIN, LOGGER +from .coordinator import SRPEnergyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -24,8 +25,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_password, ) + coordinator = SRPEnergyDataUpdateCoordinator( + hass, api_instance, entry.data[CONF_IS_TOU] + ) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = api_instance + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py new file mode 100644 index 00000000000..a72ea4d3334 --- /dev/null +++ b/homeassistant/components/srp_energy/coordinator.py @@ -0,0 +1,75 @@ +"""DataUpdateCoordinator for the srp_energy integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from srpenergy.client import SrpEnergyClient + +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, LOGGER, MIN_TIME_BETWEEN_UPDATES, PHOENIX_TIME_ZONE + +TIMEOUT = 10 + + +class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): + """A srp_energy Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, client: SrpEnergyClient, is_time_of_use: bool + ) -> None: + """Initialize the srp_energy data coordinator.""" + self._client = client + self._is_time_of_use = is_time_of_use + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + async def _async_update_data(self) -> float: + """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. + """ + LOGGER.debug("async_update_data enter") + # Fetch srp_energy data + phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) + end_date = dt_util.now(phx_time_zone) + start_date = end_date - timedelta(days=1) + try: + async with asyncio.timeout(TIMEOUT): + hourly_usage = await self.hass.async_add_executor_job( + self._client.usage, + start_date, + end_date, + self._is_time_of_use, + ) + except (ValueError, TypeError) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + LOGGER.debug( + "async_update_data: Received %s record(s) from %s to %s", + len(hourly_usage) if hourly_usage else "None", + start_date, + end_date, + ) + + previous_daily_usage = 0.0 + for _, _, _, kwh, _ in hourly_usage: + previous_daily_usage += float(kwh) + + LOGGER.debug( + "async_update_data: previous_daily_usage %s", + previous_daily_usage, + ) + + return previous_daily_usage diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index cdfd53d40a0..a7f0f97b636 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -1,11 +1,6 @@ """Support for SRP Energy Sensor.""" from __future__ import annotations -from datetime import timedelta - -import async_timeout -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -15,101 +10,37 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_IS_TOU, - DEFAULT_NAME, - DOMAIN, - LOGGER, - MIN_TIME_BETWEEN_UPDATES, - PHOENIX_TIME_ZONE, - SENSOR_NAME, - SENSOR_TYPE, -) +from . import SRPEnergyDataUpdateCoordinator +from .const import DEFAULT_NAME, DOMAIN, SENSOR_NAME async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the SRP Energy Usage sensor.""" - # API object stored here by __init__.py - api = hass.data[DOMAIN][entry.entry_id] - is_time_of_use = entry.data[CONF_IS_TOU] + coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - 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. - """ - LOGGER.debug("async_update_data enter") - try: - # Fetch srp_energy data - phx_time_zone = dt_util.get_time_zone(PHOENIX_TIME_ZONE) - end_date = dt_util.now(phx_time_zone) - start_date = end_date - timedelta(days=1) - - async with async_timeout.timeout(10): - hourly_usage = await hass.async_add_executor_job( - api.usage, - start_date, - end_date, - is_time_of_use, - ) - - LOGGER.debug( - "async_update_data: Received %s record(s) from %s to %s", - len(hourly_usage) if hourly_usage else "None", - start_date, - end_date, - ) - - previous_daily_usage = 0.0 - for _, _, _, kwh, _ in hourly_usage: - previous_daily_usage += float(kwh) - - LOGGER.debug( - "async_update_data: previous_daily_usage %s", - previous_daily_usage, - ) - - return previous_daily_usage - except TimeoutError as timeout_err: - raise UpdateFailed("Timeout communicating with API") from timeout_err - except (ConnectError, HTTPError, Timeout, ValueError, TypeError) as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - LOGGER, - name="sensor", - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) - - # Fetch initial data so we have data when entities subscribe - await coordinator.async_refresh() - - async_add_entities([SrpEntity(coordinator)]) + async_add_entities([SrpEntity(coordinator, entry)]) -class SrpEntity(SensorEntity): +class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity): """Implementation of a Srp Energy Usage sensor.""" _attr_attribution = "Powered by SRP Energy" _attr_icon = "mdi:flash" - _attr_should_poll = False + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING - def __init__(self, coordinator) -> None: + def __init__( + self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry + ) -> None: """Initialize the SrpEntity class.""" + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry.entry_id}_total_usage" self._name = SENSOR_NAME - self.type = SENSOR_TYPE - self.coordinator = coordinator - self._unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - self._state = None @property def name(self) -> str: @@ -117,41 +48,6 @@ class SrpEntity(SensorEntity): return f"{DEFAULT_NAME} {self._name}" @property - def native_value(self) -> StateType: + def native_value(self) -> float: """Return the state of the device.""" return self.coordinator.data - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self.coordinator.last_update_success - - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class.""" - return SensorDeviceClass.ENERGY - - @property - def state_class(self) -> SensorStateClass: - """Return the state class.""" - return SensorStateClass.TOTAL_INCREASING - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - if self.coordinator.data: - self._state = self.coordinator.data - - async def async_update(self) -> None: - """Update the entity. - - Only used by the generic entity update service. - """ - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 4bc9bb24835..3be5475a71a 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -153,7 +153,7 @@ async def async_register_callback( @bind_hass -async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_udn_st( hass: HomeAssistant, udn: str, st: str ) -> SsdpServiceInfo | None: """Fetch the discovery info cache.""" @@ -162,7 +162,7 @@ async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name @bind_hass -async def async_get_discovery_info_by_st( # pylint: disable=invalid-name +async def async_get_discovery_info_by_st( hass: HomeAssistant, st: str ) -> list[SsdpServiceInfo]: """Fetch all the entries matching the st.""" @@ -575,7 +575,7 @@ class Scanner: info_desc = await self._async_get_description_dict(location) return discovery_info_from_headers_and_description(headers, info_desc) - async def async_get_discovery_info_by_udn_st( # pylint: disable=invalid-name + async def async_get_discovery_info_by_udn_st( self, udn: str, st: str ) -> SsdpServiceInfo | None: """Return discovery_info for a udn and st.""" @@ -583,9 +583,7 @@ class Scanner: return await self._async_headers_to_discovery_info(headers) return None - async def async_get_discovery_info_by_st( # pylint: disable=invalid-name - self, st: str - ) -> list[SsdpServiceInfo]: + async def async_get_discovery_info_by_st(self, st: str) -> list[SsdpServiceInfo]: """Return matching discovery_infos for a st.""" return [ await self._async_headers_to_discovery_info(headers) diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 61b6b05d9d6..a6eb95933b4 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.34.1"] + "requirements": ["async-upnp-client==0.35.0"] } diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index 920c9214aec..f0dea666085 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -9,8 +9,9 @@ from starline import StarlineApi, StarlineDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import dt as dt_util from .const import ( _LOGGER, @@ -141,7 +142,9 @@ class StarlineAccount: def gps_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for device tracker.""" return { - "updated": datetime.utcfromtimestamp(device.position["ts"]).isoformat(), + "updated": dt_util.utc_from_timestamp(device.position["ts"]) + .replace(tzinfo=None) + .isoformat(), "online": device.online, } diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index c59269d2e07..3413c4ff595 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -11,6 +11,7 @@ from .coordinator import StarlinkUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/starlink/config_flow.py b/homeassistant/components/starlink/config_flow.py index 4154ef09adf..987a84796f1 100644 --- a/homeassistant/components/starlink/config_flow.py +++ b/homeassistant/components/starlink/config_flow.py @@ -44,6 +44,7 @@ class StarlinkConfigFlow(ConfigFlow, domain=DOMAIN): async def get_device_id(self, url: str) -> str | None: """Get the device UID, or None if no device exists at the given URL.""" context = ChannelContext(target=url) + response: str | None try: response = await self.hass.async_add_executor_job(get_id, context) except GrpcError: diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 56d25bf2d1a..95a5515ab21 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -1,17 +1,19 @@ """Contains the shared Coordinator for Starlink systems.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import timedelta import logging -import async_timeout from starlink_grpc import ( AlertDict, ChannelContext, GrpcError, + LocationDict, ObstructionDict, StatusDict, + location_data, reboot, set_stow_state, status_data, @@ -28,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) class StarlinkData: """Contains data pulled from the Starlink system.""" + location: LocationDict status: StatusDict obstruction: ObstructionDict alert: AlertDict @@ -48,18 +51,21 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): ) async def _async_update_data(self) -> StarlinkData: - async with async_timeout.timeout(4): + async with asyncio.timeout(4): try: status = await self.hass.async_add_executor_job( status_data, self.channel_context ) - return StarlinkData(*status) + location = await self.hass.async_add_executor_job( + location_data, self.channel_context + ) + return StarlinkData(location, *status) except GrpcError as exc: raise UpdateFailed from exc - async def async_stow_starlink(self, stow: bool): + async def async_stow_starlink(self, stow: bool) -> None: """Set whether Starlink system tied to this coordinator should be stowed.""" - async with async_timeout.timeout(4): + async with asyncio.timeout(4): try: await self.hass.async_add_executor_job( set_stow_state, not stow, self.channel_context @@ -67,9 +73,9 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): except GrpcError as exc: raise HomeAssistantError from exc - async def async_reboot_starlink(self): + async def async_reboot_starlink(self) -> None: """Reboot the Starlink system tied to this coordinator.""" - async with async_timeout.timeout(4): + async with asyncio.timeout(4): try: await self.hass.async_add_executor_job(reboot, self.channel_context) except GrpcError as exc: diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py new file mode 100644 index 00000000000..eb832741f40 --- /dev/null +++ b/homeassistant/components/starlink/device_tracker.py @@ -0,0 +1,73 @@ +"""Contains device trackers exposed by the Starlink integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.device_tracker import SourceType, TrackerEntity +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 .coordinator import StarlinkData +from .entity import StarlinkEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up all binary sensors for this entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + StarlinkDeviceTrackerEntity(coordinator, description) + for description in DEVICE_TRACKERS + ) + + +@dataclass +class StarlinkDeviceTrackerEntityDescriptionMixin: + """Describes a Starlink device tracker.""" + + latitude_fn: Callable[[StarlinkData], float] + longitude_fn: Callable[[StarlinkData], float] + + +@dataclass +class StarlinkDeviceTrackerEntityDescription( + EntityDescription, StarlinkDeviceTrackerEntityDescriptionMixin +): + """Describes a Starlink button entity.""" + + +DEVICE_TRACKERS = [ + StarlinkDeviceTrackerEntityDescription( + key="device_location", + translation_key="device_location", + entity_registry_enabled_default=False, + latitude_fn=lambda data: data.location["latitude"], + longitude_fn=lambda data: data.location["longitude"], + ), +] + + +class StarlinkDeviceTrackerEntity(StarlinkEntity, TrackerEntity): + """A TrackerEntity for Starlink devices. Handles creating unique IDs.""" + + entity_description: StarlinkDeviceTrackerEntityDescription + + @property + def source_type(self) -> SourceType | str: + """Return the source type, eg gps or router, of the device.""" + return SourceType.GPS + + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self.entity_description.latitude_fn(self.coordinator.data) + + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self.entity_description.longitude_fn(self.coordinator.data) diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py index 10711e7155e..88e6485cf77 100644 --- a/homeassistant/components/starlink/diagnostics.py +++ b/homeassistant/components/starlink/diagnostics.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import StarlinkUpdateCoordinator -TO_REDACT = {"id"} +TO_REDACT = {"id", "latitude", "longitude", "altitude"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/starlink/entity.py b/homeassistant/components/starlink/entity.py index 29ef9ba9f08..b726beeef0d 100644 --- a/homeassistant/components/starlink/entity.py +++ b/homeassistant/components/starlink/entity.py @@ -1,7 +1,8 @@ """Contains base entity classes for Starlink entities.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index 2230259dcc4..c719afa968d 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["starlink-grpc-core==1.1.1"] + "requirements": ["starlink-grpc-core==1.1.2"] } diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index a9e50f5d39f..0ec85c68956 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -44,6 +44,11 @@ "name": "Unexpected location" } }, + "device_tracker": { + "device_location": { + "name": "Device location" + } + }, "sensor": { "ping": { "name": "Ping" diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 3334afded00..ab53b039756 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -1,12 +1,12 @@ """Support for Start.ca Bandwidth Monitor.""" from __future__ import annotations +import asyncio from datetime import timedelta from http import HTTPStatus import logging from xml.parsers.expat import ExpatError -import async_timeout import voluptuous as vol import xmltodict @@ -157,6 +157,13 @@ async def async_setup_platform( name = config[CONF_NAME] monitored_variables = config[CONF_MONITORED_VARIABLES] + if bandwidthcap <= 0: + monitored_variables = list( + filter( + lambda itm: itm not in {"limit", "usage", "used_remaining"}, + monitored_variables, + ) + ) entities = [ StartcaSensor(ts_data, name, description) for description in SENSOR_TYPES @@ -193,11 +200,9 @@ class StartcaData: self.api_key = api_key self.bandwidth_cap = bandwidth_cap # Set unlimited users to infinite, otherwise the cap. - self.data = ( - {"limit": self.bandwidth_cap} - if self.bandwidth_cap > 0 - else {"limit": float("inf")} - ) + self.data = {} + if self.bandwidth_cap > 0: + self.data["limit"] = self.bandwidth_cap @staticmethod def bytes_to_gb(value): @@ -213,7 +218,7 @@ class StartcaData: """Get the Start.ca bandwidth data from the web service.""" _LOGGER.debug("Updating Start.ca usage data") url = f"https://www.start.ca/support/usage/api?key={self.api_key}" - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): req = await self.websession.get(url) if req.status != HTTPStatus.OK: _LOGGER.error("Request failed with status: %u", req.status) @@ -232,11 +237,9 @@ class StartcaData: total_dl = self.bytes_to_gb(xml_data["usage"]["total"]["download"]) total_ul = self.bytes_to_gb(xml_data["usage"]["total"]["upload"]) - limit = self.data["limit"] if self.bandwidth_cap > 0: self.data["usage"] = 100 * used_dl / self.bandwidth_cap - else: - self.data["usage"] = 0 + self.data["used_remaining"] = self.data["limit"] - used_dl self.data["usage_gb"] = used_dl self.data["used_download"] = used_dl self.data["used_upload"] = used_ul @@ -246,6 +249,5 @@ class StartcaData: self.data["grace_total"] = grace_dl + grace_ul self.data["total_download"] = total_dl self.data["total_upload"] = total_ul - self.data["used_remaining"] = limit - used_dl return True diff --git a/homeassistant/components/steam_online/entity.py b/homeassistant/components/steam_online/entity.py index 364f2e72328..8ad6bd8c713 100644 --- a/homeassistant/components/steam_online/entity.py +++ b/homeassistant/components/steam_online/entity.py @@ -1,6 +1,5 @@ """Entity classes for the Steam integration.""" -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/steamist/coordinator.py b/homeassistant/components/steamist/coordinator.py index 0ab603ddd17..67aedf0af94 100644 --- a/homeassistant/components/steamist/coordinator.py +++ b/homeassistant/components/steamist/coordinator.py @@ -30,6 +30,7 @@ class SteamistDataUpdateCoordinator(DataUpdateCoordinator[SteamistStatus]): _LOGGER, name=f"Steamist {host}", update_interval=timedelta(seconds=5), + always_update=False, ) async def _async_update_data(self) -> SteamistStatus: diff --git a/homeassistant/components/steamist/entity.py b/homeassistant/components/steamist/entity.py index 4692b48d314..94b3d32eaa4 100644 --- a/homeassistant/components/steamist/entity.py +++ b/homeassistant/components/steamist/entity.py @@ -6,7 +6,8 @@ from aiosteamist import SteamistStatus from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import SteamistDataUpdateCoordinator diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 84a39e3c875..13ca12f482e 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -5,11 +5,6 @@ import logging from pystiebeleltron import pystiebeleltron import voluptuous as vol -from homeassistant.components.modbus import ( - CONF_HUB, - DEFAULT_HUB, - DOMAIN as MODBUS_DOMAIN, -) from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery @@ -17,6 +12,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle +CONF_HUB = "hub" +DEFAULT_HUB = "modbus_hub" +MODBUS_DOMAIN = "modbus" DOMAIN = "stiebel_eltron" CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py index 1d074bba9c2..0ee087a779e 100644 --- a/homeassistant/components/stookalert/binary_sensor.py +++ b/homeassistant/components/stookalert/binary_sensor.py @@ -11,8 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_PROVINCE, DOMAIN diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 5b0bc4d4c63..312f8bdd02d 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -8,8 +8,7 @@ from stookwijzer import Stookwijzer from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, StookwijzerState diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 63269401a40..691ba262ee2 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -537,6 +537,7 @@ class Stream: self, width: int | None = None, height: int | None = None, + wait_for_next_keyframe: bool = False, ) -> bytes | None: """Fetch an image from the Stream and return it as a jpeg in bytes. @@ -548,7 +549,9 @@ class Stream: self.add_provider(HLS_PROVIDER) await self.start() return await self._keyframe_converter.async_get_image( - width=width, height=height + width=width, + height=height, + wait_for_next_keyframe=wait_for_next_keyframe, ) def get_diagnostics(self) -> dict[str, Any]: diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index cc3c0abb96c..6b8e6c44a1c 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -10,7 +10,6 @@ import logging from typing import TYPE_CHECKING, Any from aiohttp import web -import async_timeout import attr import numpy as np @@ -332,7 +331,7 @@ class StreamOutput: async def part_recv(self, timeout: float | None = None) -> bool: """Wait for an event signalling the latest part segment.""" try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await self._part_event.wait() except asyncio.TimeoutError: return False @@ -442,7 +441,8 @@ class KeyFrameConverter: # pylint: disable-next=import-outside-toplevel from homeassistant.components.camera.img_util import TurboJPEGSingleton - self.packet: Packet = None + self._packet: Packet = None + self._event: asyncio.Event = asyncio.Event() self._hass = hass self._image: bytes | None = None self._turbojpeg = TurboJPEGSingleton.instance() @@ -451,6 +451,14 @@ class KeyFrameConverter: self._stream_settings = stream_settings self._dynamic_stream_settings = dynamic_stream_settings + def stash_keyframe_packet(self, packet: Packet) -> None: + """Store the keyframe and set the asyncio.Event from the event loop. + + This is called from the worker thread. + """ + self._packet = packet + self._hass.loop.call_soon_threadsafe(self._event.set) + def create_codec_context(self, codec_context: CodecContext) -> None: """Create a codec context to be used for decoding the keyframes. @@ -483,10 +491,10 @@ class KeyFrameConverter: at a time per instance. """ - if not (self._turbojpeg and self.packet and self._codec_context): + if not (self._turbojpeg and self._packet and self._codec_context): return - packet = self.packet - self.packet = None + packet = self._packet + self._packet = None for _ in range(2): # Retry once if codec context needs to be flushed try: # decode packet (flush afterwards) @@ -520,10 +528,14 @@ class KeyFrameConverter: self, width: int | None = None, height: int | None = None, + wait_for_next_keyframe: bool = False, ) -> bytes | None: """Fetch an image from the Stream and return it as a jpeg in bytes.""" # Use a lock to ensure only one thread is working on the keyframe at a time + if wait_for_next_keyframe: + self._event.clear() + await self._event.wait() async with self._lock: await self._hass.async_add_executor_job(self._generate_image, width, height) return self._image diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 258457a3d82..a334171abb8 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -1,6 +1,7 @@ """Provide functionality to record stream.""" from __future__ import annotations +from collections import deque from io import DEFAULT_BUFFER_SIZE, BytesIO import logging import os @@ -19,8 +20,6 @@ from .core import PROVIDERS, IdleTimer, Segment, StreamOutput, StreamSettings from .fmp4utils import read_init, transform_init if TYPE_CHECKING: - import deque - from homeassistant.components.camera import DynamicStreamSettings _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index c237a820e58..cc4970c8a5e 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -14,6 +14,7 @@ import attr import av from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import redact_credentials from .const import ( @@ -140,7 +141,7 @@ class StreamMuxer: self._part_has_keyframe = False self._stream_settings = stream_settings self._stream_state = stream_state - self._start_time = datetime.datetime.utcnow() + self._start_time = dt_util.utcnow() def make_new_av( self, @@ -623,4 +624,4 @@ def stream_worker( muxer.mux_packet(packet) if packet.is_keyframe and is_video(packet): - keyframe_converter.packet = packet + keyframe_converter.stash_keyframe_packet(packet) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 49ad3cf0d98..091a281defc 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_US from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 42badfc0185..9c94ed35361 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -37,6 +37,7 @@ API_GEN_3 = "g3" MANUFACTURER = "Subaru" PLATFORMS = [ + Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR, ] diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py new file mode 100644 index 00000000000..4a8cb8ad5ee --- /dev/null +++ b/homeassistant/components/subaru/device_tracker.py @@ -0,0 +1,91 @@ +"""Support for Subaru device tracker.""" +from __future__ import annotations + +from typing import Any + +from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import get_device_info +from .const import ( + DOMAIN, + ENTRY_COORDINATOR, + ENTRY_VEHICLES, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_STATUS, + VEHICLE_VIN, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Subaru device tracker by config_entry.""" + entry: dict = hass.data[DOMAIN][config_entry.entry_id] + coordinator: DataUpdateCoordinator = entry[ENTRY_COORDINATOR] + vehicle_info: dict = entry[ENTRY_VEHICLES] + entities: list[SubaruDeviceTracker] = [] + for vehicle in vehicle_info.values(): + if vehicle[VEHICLE_HAS_REMOTE_SERVICE]: + entities.append(SubaruDeviceTracker(vehicle, coordinator)) + async_add_entities(entities) + + +class SubaruDeviceTracker( + CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]], TrackerEntity +): + """Class for Subaru device tracker.""" + + _attr_icon = "mdi:car" + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, vehicle_info: dict, coordinator: DataUpdateCoordinator) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator) + self.vin = vehicle_info[VEHICLE_VIN] + self._attr_device_info = get_device_info(vehicle_info) + self._attr_unique_id = f"{self.vin}_location" + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + return { + "Position timestamp": self.coordinator.data[self.vin][VEHICLE_STATUS].get( + TIMESTAMP + ) + } + + @property + def latitude(self) -> float | None: + """Return latitude value of the vehicle.""" + return self.coordinator.data[self.vin][VEHICLE_STATUS].get(LATITUDE) + + @property + def longitude(self) -> float | None: + """Return longitude value of the vehicle.""" + return self.coordinator.data[self.vin][VEHICLE_STATUS].get(LONGITUDE) + + @property + def source_type(self) -> SourceType: + """Return the source type of the vehicle.""" + return SourceType.GPS + + @property + def available(self) -> bool: + """Return if entity is available.""" + if vehicle_data := self.coordinator.data.get(self.vin): + if status := vehicle_data.get(VEHICLE_STATUS): + return status.keys() & {LATITUDE, LONGITUDE, TIMESTAMP} + return False diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 344e0c2179e..6eccbc93d37 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 0d1308ca5a6..9652cae4aa4 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -1,10 +1,10 @@ """Support for Supla devices.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from asyncpysupla import SuplaAPI import voluptuous as vol @@ -99,10 +99,9 @@ async def discover_devices(hass, hass_config): for server_name, server in hass.data[DOMAIN][SUPLA_SERVERS].items(): async def _fetch_channels(): - async with async_timeout.timeout(SCAN_INTERVAL.total_seconds()): + async with asyncio.timeout(SCAN_INTERVAL.total_seconds()): channels = { channel["id"]: channel - # pylint: disable-next=cell-var-from-loop for channel in await server.get_channels( # noqa: B023 include=["iodevice", "state", "connected"] ) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index ec5d9f63920..9189ea38c00 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -18,10 +18,15 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + 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 @@ -88,6 +93,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 diff --git a/homeassistant/components/surepetcare/entity.py b/homeassistant/components/surepetcare/entity.py index 75d7f4e1c30..e6a44d5bfa9 100644 --- a/homeassistant/components/surepetcare/entity.py +++ b/homeassistant/components/surepetcare/entity.py @@ -6,7 +6,7 @@ from abc import abstractmethod from surepy.entities import SurepyEntity from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SurePetcareDataCoordinator diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index 3718c4ebe99..52d58157e34 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -14,7 +14,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index 4f6a056202c..6aae5adb3d6 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -5,7 +5,7 @@ from typing import Generic, TypeVar, cast from switchbee import SWITCHBEE_BRAND from switchbee.device import DeviceType, SwitchBeeBaseDevice -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index c12e8122e52..39f2a4aa6da 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -6,7 +6,6 @@ import contextlib import logging from typing import TYPE_CHECKING -import async_timeout import switchbot from switchbot import SwitchbotModel @@ -117,7 +116,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) async def async_wait_ready(self) -> bool: """Wait for the device to be ready.""" with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(DEVICE_STARTUP_TIMEOUT): + async with asyncio.timeout(DEVICE_STARTUP_TIMEOUT): await self._ready_event.wait() return True return False diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index c0e7a51170a..cf7f97a2692 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -13,7 +13,8 @@ from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.const import ATTR_CONNECTIONS from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, ToggleEntity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import ToggleEntity from .const import MANUFACTURER from .coordinator import SwitchbotDataUpdateCoordinator diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index 7710cde12a9..60f4fe66c26 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -4,7 +4,7 @@ from typing import Any import switchbot from switchbot.const import LockStatus -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,6 +34,8 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): """Initialize the entity.""" super().__init__(coordinator) self._async_update_attrs() + if self._device.is_night_latch_enabled(): + self._attr_supported_features = LockEntityFeature.OPEN def _async_update_attrs(self) -> None: """Update the entity attributes.""" @@ -53,5 +55,13 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" + if self._device.is_night_latch_enabled(): + self._last_run_success = await self._device.unlock_without_unlatch() + else: + self._last_run_success = await self._device.unlock() + self.async_write_ha_state() + + async def async_open(self, **kwargs: Any) -> None: + """Open the lock.""" self._last_run_success = await self._device.unlock() self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index e45ea1f893e..2259a450559 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -28,7 +28,6 @@ } ], "codeowners": [ - "@bdraco", "@danielhiversen", "@RenierM26", "@murtas", @@ -40,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.38.0"] + "requirements": ["PySwitchbot==0.39.1"] } diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index ec2f4c0bc90..4303c885106 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -20,8 +20,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -48,7 +48,7 @@ class SwitcherThermostatButtonEntityDescription( THERMOSTAT_BUTTONS = [ SwitcherThermostatButtonEntityDescription( key="assume_on", - name="Assume on", + translation_key="assume_on", icon="mdi:fan", entity_category=EntityCategory.CONFIG, press_fn=lambda api, remote: api.control_breeze_device( @@ -58,7 +58,7 @@ THERMOSTAT_BUTTONS = [ ), SwitcherThermostatButtonEntityDescription( key="assume_off", - name="Assume off", + translation_key="assume_off", icon="mdi:fan-off", entity_category=EntityCategory.CONFIG, press_fn=lambda api, remote: api.control_breeze_device( @@ -68,7 +68,7 @@ THERMOSTAT_BUTTONS = [ ), SwitcherThermostatButtonEntityDescription( key="vertical_swing_on", - name="Vertical swing on", + translation_key="vertical_swing_on", icon="mdi:autorenew", press_fn=lambda api, remote: api.control_breeze_device( remote, swing=ThermostatSwing.ON @@ -77,7 +77,7 @@ THERMOSTAT_BUTTONS = [ ), SwitcherThermostatButtonEntityDescription( key="vertical_swing_off", - name="Vertical swing off", + translation_key="vertical_swing_off", icon="mdi:autorenew-off", press_fn=lambda api, remote: api.control_breeze_device( remote, swing=ThermostatSwing.OFF @@ -117,6 +117,7 @@ class SwitcherThermostatButtonEntity( """Representation of a Switcher climate entity.""" entity_description: SwitcherThermostatButtonEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -129,7 +130,6 @@ class SwitcherThermostatButtonEntity( self.entity_description = description self._remote = remote - self._attr_name = f"{coordinator.name} {description.name}" self._attr_unique_id = f"{coordinator.mac_address}-{description.key}" self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index be966d67eef..809e3d6a3ad 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -30,8 +30,8 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature 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 DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -84,6 +84,9 @@ class SwitcherClimateEntity( ): """Representation of a Switcher climate entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote ) -> None: @@ -91,7 +94,6 @@ class SwitcherClimateEntity( super().__init__(coordinator) self._remote = remote - self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 1d72184ad4d..c627f361d7d 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -18,8 +18,8 @@ from homeassistant.config_entries import ConfigEntry 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 DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -55,6 +55,8 @@ class SwitcherCoverEntity( ): """Representation of a Switcher cover entity.""" + _attr_has_entity_name = True + _attr_name = None _attr_device_class = CoverDeviceClass.SHUTTER _attr_supported_features = ( CoverEntityFeature.OPEN @@ -67,7 +69,6 @@ class SwitcherCoverEntity( """Initialize the entity.""" super().__init__(coordinator) - self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 2c74f14cb5c..e9fa13fca8a 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -1,19 +1,19 @@ """Switcher integration Sensor platform.""" from __future__ import annotations -from dataclasses import dataclass - from aioswitcher.device import DeviceCategory from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent, UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -22,48 +22,36 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SwitcherDataUpdateCoordinator from .const import SIGNAL_DEVICE_ADD - -@dataclass -class AttributeDescription: - """Class to describe a sensor.""" - - name: str - icon: str | None = None - unit: str | None = None - device_class: SensorDeviceClass | None = None - state_class: SensorStateClass | None = None - default_enabled: bool = True - - -POWER_SENSORS = { - "power_consumption": AttributeDescription( - name="Power Consumption", - unit=UnitOfPower.WATT, +POWER_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="power_consumption", + native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - "electric_current": AttributeDescription( - name="Electric Current", - unit=UnitOfElectricCurrent.AMPERE, + SensorEntityDescription( + key="electric_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), -} - -TIME_SENSORS = { - "remaining_time": AttributeDescription( - name="Remaining Time", +] +TIME_SENSORS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="remaining_time", + translation_key="remaining_time", icon="mdi:av-timer", ), - "auto_off_set": AttributeDescription( - name="Auto Shutdown", + SensorEntityDescription( + key="auto_off_set", + translation_key="auto_shutdown", icon="mdi:progress-clock", - default_enabled=False, + entity_registry_enabled_default=False, ), -} +] POWER_PLUG_SENSORS = POWER_SENSORS -WATER_HEATER_SENSORS = {**POWER_SENSORS, **TIME_SENSORS} +WATER_HEATER_SENSORS = [*POWER_SENSORS, *TIME_SENSORS] async def async_setup_entry( @@ -78,13 +66,13 @@ async def async_setup_entry( """Add sensors from Switcher device.""" if coordinator.data.device_type.category == DeviceCategory.POWER_PLUG: async_add_entities( - SwitcherSensorEntity(coordinator, attribute, info) - for attribute, info in POWER_PLUG_SENSORS.items() + SwitcherSensorEntity(coordinator, description) + for description in POWER_PLUG_SENSORS ) elif coordinator.data.device_type.category == DeviceCategory.WATER_HEATER: async_add_entities( - SwitcherSensorEntity(coordinator, attribute, info) - for attribute, info in WATER_HEATER_SENSORS.items() + SwitcherSensorEntity(coordinator, description) + for description in WATER_HEATER_SENSORS ) config_entry.async_on_unload( @@ -97,31 +85,25 @@ class SwitcherSensorEntity( ): """Representation of a Switcher sensor entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: SwitcherDataUpdateCoordinator, - attribute: str, - description: AttributeDescription, + description: SensorEntityDescription, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self.attribute = attribute - - # Entity class attributes - self._attr_name = f"{coordinator.name} {description.name}" - self._attr_icon = description.icon - self._attr_native_unit_of_measurement = description.unit - self._attr_device_class = description.device_class - self._attr_entity_registry_enabled_default = description.default_enabled + self.entity_description = description self._attr_unique_id = ( - f"{coordinator.device_id}-{coordinator.mac_address}-{attribute}" + f"{coordinator.device_id}-{coordinator.mac_address}-{description.key}" + ) + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) - self._attr_device_info = { - "connections": {(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} - } @property def native_value(self) -> StateType: """Return value of sensor.""" - return getattr(self.coordinator.data, self.attribute) # type: ignore[no-any-return] + return getattr(self.coordinator.data, self.entity_description.key) # type: ignore[no-any-return] diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index 4c4080a8394..e21bdbcdf7a 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -10,6 +10,30 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } }, + "entity": { + "button": { + "assume_on": { + "name": "Assume on" + }, + "assume_off": { + "name": "Assume off" + }, + "vertical_swing_on": { + "name": "Vertical swing on" + }, + "vertical_swing_off": { + "name": "Vertical swing off" + } + }, + "sensor": { + "remaining_time": { + "name": "Remaining time" + }, + "auto_shutdown": { + "name": "Auto shutdown" + } + } + }, "services": { "set_auto_off": { "name": "Set auto off", diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index caed3c3c320..ef8564b3770 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -18,8 +18,8 @@ from homeassistant.helpers import ( device_registry as dr, entity_platform, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -83,13 +83,15 @@ class SwitcherBaseSwitchEntity( ): """Representation of a Switcher switch entity.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) self.control_result: bool | None = None # Entity class attributes - self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} @@ -118,7 +120,7 @@ class SwitcherBaseSwitchEntity( if error or not response or not response.successful: _LOGGER.error( "Call api for %s failed, api: '%s', args: %s, response/error: %s", - self.name, + self.coordinator.name, api, args, response or error, @@ -150,7 +152,7 @@ class SwitcherBaseSwitchEntity( _LOGGER.warning( "Service '%s' is not supported by %s", SERVICE_SET_AUTO_OFF_NAME, - self.name, + self.coordinator.name, ) async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: @@ -158,7 +160,7 @@ class SwitcherBaseSwitchEntity( _LOGGER.warning( "Service '%s' is not supported by %s", SERVICE_TURN_ON_WITH_TIMER_NAME, - self.name, + self.coordinator.name, ) diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 33fcb93182e..0551ae29d2c 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -5,9 +5,8 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index 424b81ac800..8d17f038819 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -1,10 +1,10 @@ """The syncthru component.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported from homeassistant.config_entries import ConfigEntry @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> SyncThru: """Fetch data from the printer.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await printer.update() except SyncThruAPINotSupported as api_error: # if an exception is thrown, printer does not support syncthru @@ -55,8 +55,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=async_update_data, update_interval=timedelta(seconds=30), ) - hass.data[DOMAIN][entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator if isinstance(coordinator.last_exception, SyncThruAPINotSupported): # this means that the printer does not support the syncthru JSON API # and the config should simply be discarded diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 66ed72a3fc9..f5e23ea25ad 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index 2ec6deccf85..c2ad159fb21 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 05e1a57aaf6..d62f816b29e 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -14,7 +14,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SynoApi diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 425475dc0d0..b76699631cb 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -17,8 +17,8 @@ from homeassistant.components.camera import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SynoApi diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 0865686ef20..bb668e292cc 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -4,7 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, TypeVar -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .common import SynoApi diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 208d299cc2e..074a423c53d 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -10,7 +10,7 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SynoApi diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 05e607d56ed..d50540f7b42 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import logging -import async_timeout from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, @@ -20,6 +19,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COMMAND, CONF_HOST, CONF_PATH, CONF_PORT, @@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MODULES @@ -48,10 +48,20 @@ CONF_KEY = "key" CONF_TEXT = "text" SERVICE_OPEN_PATH = "open_path" +SERVICE_POWER_COMMAND = "power_command" SERVICE_OPEN_URL = "open_url" SERVICE_SEND_KEYPRESS = "send_keypress" SERVICE_SEND_TEXT = "send_text" +POWER_COMMAND_MAP = { + "hibernate": "power_hibernate", + "lock": "power_lock", + "logout": "power_logout", + "restart": "power_restart", + "shutdown": "power_shutdown", + "sleep": "power_sleep", +} + async def async_setup_entry( hass: HomeAssistant, @@ -67,7 +77,7 @@ async def async_setup_entry( session=async_get_clientsession(hass), ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): if not await version.check_supported(): raise ConfigEntryNotReady( "You are not running a supported version of System Bridge. Please" @@ -91,7 +101,7 @@ async def async_setup_entry( entry=entry, ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await coordinator.async_get_data(MODULES) except AuthenticationException as exception: _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) @@ -109,7 +119,7 @@ async def async_setup_entry( try: # Wait for initial data - async with async_timeout.timeout(10): + async with asyncio.timeout(10): while not coordinator.is_ready: _LOGGER.debug( "Waiting for initial data from %s (%s)", @@ -137,7 +147,7 @@ async def async_setup_entry( if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): return True - def valid_device(device: str): + def valid_device(device: str) -> str: """Check device is valid.""" device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device) @@ -162,6 +172,17 @@ async def async_setup_entry( OpenPath(path=call.data[CONF_PATH]) ) + async def handle_power_command(call: ServiceCall) -> None: + """Handle the power command service call.""" + _LOGGER.info("Power command: %s", call.data) + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + call.data[CONF_BRIDGE] + ] + await getattr( + coordinator.websocket_client, + POWER_COMMAND_MAP[call.data[CONF_COMMAND]], + )() + async def handle_open_url(call: ServiceCall) -> None: """Handle the open url service call.""" _LOGGER.info("Open: %s", call.data) @@ -200,6 +221,18 @@ async def async_setup_entry( ), ) + hass.services.async_register( + DOMAIN, + SERVICE_POWER_COMMAND, + handle_power_command, + schema=vol.Schema( + { + vol.Required(CONF_BRIDGE): valid_device, + vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP), + }, + ), + ) + hass.services.async_register( DOMAIN, SERVICE_OPEN_URL, @@ -274,19 +307,19 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): """Defines a base System Bridge entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: SystemBridgeDataUpdateCoordinator, api_port: int, key: str, - name: str | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) self._hostname = coordinator.data.system.hostname self._key = f"{self._hostname}_{key}" - self._name = f"{self._hostname} {name}" self._configuration_url = ( f"http://{self._hostname}:{api_port}/app/settings.html" ) @@ -299,11 +332,6 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): """Return the unique ID for this entity.""" return self._key - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - @property def device_info(self) -> DeviceInfo: """Return device information about this System Bridge instance.""" diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 5c23c3110d8..e3ecc3817a6 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -33,7 +33,6 @@ class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="version_available", - name="New version available", device_class=BinarySensorDeviceClass.UPDATE, value=lambda data: data.system.version_newer_available, ), @@ -42,7 +41,6 @@ BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="battery_is_charging", - name="Battery is charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, value=lambda data: data.battery.is_charging, ), @@ -92,7 +90,6 @@ class SystemBridgeBinarySensor(SystemBridgeEntity, BinarySensorEntity): coordinator, api_port, description.key, - description.name, ) self.entity_description = description diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index a73740e5dbd..a7dea5d6ab2 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -6,7 +6,6 @@ from collections.abc import Mapping import logging from typing import Any -import async_timeout from systembridgeconnector.exceptions import ( AuthenticationException, ConnectionClosedException, @@ -55,7 +54,7 @@ async def _validate_input( data[CONF_API_KEY], ) try: - async with async_timeout.timeout(15): + async with asyncio.timeout(15): await websocket_client.connect(session=async_get_clientsession(hass)) hass.async_create_task(websocket_client.listen()) response = await websocket_client.get_data(GetData(modules=["system"])) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index adb88efd5ec..145e01ed29a 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -7,7 +7,6 @@ from datetime import timedelta import logging from typing import Any -import async_timeout from pydantic import BaseModel # pylint: disable=no-name-in-module from systembridgeconnector.exceptions import ( AuthenticationException, @@ -183,7 +182,7 @@ class SystemBridgeDataUpdateCoordinator( async def _setup_websocket(self) -> None: """Use WebSocket for updates.""" try: - async with async_timeout.timeout(20): + async with asyncio.timeout(20): await self.websocket_client.connect( session=async_get_clientsession(self.hass), ) diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 9290ebeacd5..151a6882e26 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Final, cast from homeassistant.components.sensor import ( @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util.dt import utcnow from . import SystemBridgeEntity @@ -46,10 +46,6 @@ PIXELS: Final = "px" class SystemBridgeSensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" - # SystemBridgeSensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - value: Callable = round @@ -143,16 +139,14 @@ def memory_used(data: SystemBridgeCoordinatorData) -> float | None: BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="boot_time", - name="Boot time", + translation_key="boot_time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:av-timer", - value=lambda data: datetime.fromtimestamp( - data.system.boot_time, tz=timezone.utc - ), + value=lambda data: datetime.fromtimestamp(data.system.boot_time, tz=UTC), ), SystemBridgeSensorEntityDescription( key="cpu_power_package", - name="CPU Package Power", + translation_key="cpu_power_package", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, @@ -161,7 +155,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_speed", - name="CPU speed", + translation_key="cpu_speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -170,7 +164,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_temperature", - name="CPU temperature", + translation_key="cpu_temperature", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -179,7 +173,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_voltage", - name="CPU voltage", + translation_key="cpu_voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -188,13 +182,13 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="kernel", - name="Kernel", + translation_key="kernel", icon="mdi:devices", value=lambda data: data.system.platform, ), SystemBridgeSensorEntityDescription( key="memory_free", - name="Memory free", + translation_key="memory_free", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -203,7 +197,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used_percentage", - name="Memory used %", + translation_key="memory_used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -211,7 +205,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used", - name="Memory used", + translation_key="amount_memory_used", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -221,13 +215,13 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="os", - name="Operating system", + translation_key="os", icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), SystemBridgeSensorEntityDescription( key="processes_load", - name="Load", + translation_key="load", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", @@ -235,13 +229,13 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="version", - name="Version", + translation_key="version", icon="mdi:counter", value=lambda data: data.system.version, ), SystemBridgeSensorEntityDescription( key="version_latest", - name="Latest version", + translation_key="version_latest", icon="mdi:counter", value=lambda data: data.system.version_latest, ), @@ -250,7 +244,6 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="battery", - name="Battery", device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -258,7 +251,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="battery_time_remaining", - name="Battery time remaining", + translation_key="battery_time_remaining", device_class=SensorDeviceClass.TIMESTAMP, value=battery_time_remaining, ), @@ -326,7 +319,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key="displays_connected", - name="Displays connected", + translation_key="displays_connected", state_class=SensorStateClass.MEASUREMENT, icon="mdi:monitor", value=lambda _, count=display_count: count, @@ -580,9 +573,10 @@ class SystemBridgeSensor(SystemBridgeEntity, SensorEntity): coordinator, api_port, description.key, - description.name, ) self.entity_description = description + if description.name != UNDEFINED: + self._attr_has_entity_name = False @property def native_value(self) -> StateType: diff --git a/homeassistant/components/system_bridge/services.yaml b/homeassistant/components/system_bridge/services.yaml index 78d6e87f218..49a7931789e 100644 --- a/homeassistant/components/system_bridge/services.yaml +++ b/homeassistant/components/system_bridge/services.yaml @@ -46,3 +46,22 @@ send_text: example: "Hello world" selector: text: +power_command: + fields: + bridge: + required: true + selector: + device: + integration: system_bridge + command: + required: true + example: "sleep" + selector: + select: + options: + - "hibernate" + - "lock" + - "logout" + - "restart" + - "shutdown" + - "sleep" diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index c3e1f949152..e8565568d20 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -28,6 +28,55 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "entity": { + "sensor": { + "boot_time": { + "name": "Boot time" + }, + "cpu_power_package": { + "name": "CPU package power" + }, + "cpu_speed": { + "name": "CPU speed" + }, + "cpu_temperature": { + "name": "CPU temperature" + }, + "cpu_voltage": { + "name": "CPU voltage" + }, + "kernel": { + "name": "Kernel" + }, + "memory_free": { + "name": "Memory free" + }, + "memory_used": { + "name": "Memory used" + }, + "amount_memory_used": { + "name": "Amount of memory used" + }, + "os": { + "name": "Operating system" + }, + "load": { + "name": "Load" + }, + "version": { + "name": "Version" + }, + "version_latest": { + "name": "Latest version" + }, + "battery_time_remaining": { + "name": "Battery time remaining" + }, + "displays_connected": { + "name": "Displays connected" + } + } + }, "services": { "open_path": { "name": "Open path", @@ -84,6 +133,20 @@ "description": "Text to type." } } + }, + "power_command": { + "name": "Power command", + "description": "Sends a power command to the system.", + "fields": { + "bridge": { + "name": "[%key:component::system_bridge::services::open_path::fields::bridge::name%]", + "description": "[%key:component::system_bridge::services::send_keypress::fields::bridge::description%]" + }, + "command": { + "name": "Command", + "description": "Command to call." + } + } } } } diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 9a222d7096c..32970bc4fe5 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -9,7 +9,6 @@ import logging from typing import Any import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components import websocket_api @@ -73,7 +72,7 @@ async def get_integration_info( """Get integration system health.""" try: assert registration.info_callback - async with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): + async with asyncio.timeout(INFO_CALLBACK_TIMEOUT): data = await registration.info_callback(hass) except asyncio.TimeoutError: data = {"error": {"type": "failed", "error": "timeout"}} diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index f025013cc2b..ab271ec676c 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -141,7 +141,7 @@ class LogEntry: self.root_cause = None if record.exc_info: self.exception = "".join(traceback.format_exception(*record.exc_info)) - _, _, tb = record.exc_info # pylint: disable=invalid-name + _, _, tb = record.exc_info # Last line of traceback contains the root cause of the exception if traceback.extract_tb(tb): self.root_cause = str(traceback.extract_tb(tb)[-1]) @@ -234,7 +234,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass_path: str = HOMEASSISTANT_PATH[0] config_dir = hass.config.config_dir - assert config_dir is not None paths_re = re.compile( r"(?:{})/(.*)".format("|".join([re.escape(x) for x in (hass_path, config_dir)])) ) diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 7f0866ce62e..4cfbdba4066 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -9,7 +9,7 @@ import logging import os import socket import sys -from typing import Any, cast +from typing import Any import psutil import voluptuous as vol @@ -613,6 +613,6 @@ def _read_cpu_temperature() -> float | None: # 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 cast(float, round(entry.current, 1)) + return round(entry.current, 1) return None diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index ec195573203..a755622ea76 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from PyTado.interface import Tado import requests.exceptions @@ -31,7 +32,9 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -66,7 +69,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -105,13 +110,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return await self.async_step_user() - def _username_already_configured(self, user_input): - """See if we already have a username matching user input configured.""" - existing_username = { - entry.data[CONF_USERNAME] for entry in self._async_current_entries() - } - return user_input[CONF_USERNAME] in existing_username - @staticmethod @callback def async_get_options_flow( @@ -122,16 +120,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for tado.""" + """Handle an option flow for Tado.""" 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=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) + return self.async_create_entry(data=user_input) data_schema = vol.Schema( { diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 4d50bc35c3b..1365c9f23a3 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -8,7 +8,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -109,7 +108,7 @@ class TadoDeviceScanner(DeviceScanner): last_results = [] try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): # Format the URL here, so we can log the template URL if # anything goes wrong without exposing username and password. url = self.tadoapiurl.format( diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 5e3065bfb53..cfc9e5b1e6e 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -1,5 +1,6 @@ """Base class for Tado entity.""" -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DEFAULT_NAME, DOMAIN, TADO_HOME, TADO_ZONE diff --git a/homeassistant/components/tailscale/__init__.py b/homeassistant/components/tailscale/__init__.py index abc4c4ca399..3d0a8e30727 100644 --- a/homeassistant/components/tailscale/__init__.py +++ b/homeassistant/components/tailscale/__init__.py @@ -6,8 +6,8 @@ from tailscale import Device as TailscaleDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index f902abc22e0..0aecbb0f405 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -56,10 +56,7 @@ def setup_platform( try: token = auth.get_token(email, password) except requests.exceptions.HTTPError as http_error: - if ( - http_error.response.status_code - == requests.codes.unauthorized # pylint: disable=no-member - ): + if http_error.response.status_code == requests.codes.unauthorized: _LOGGER.error("Invalid credentials") return @@ -121,8 +118,8 @@ class TankUtilitySensor(SensorEntity): data = tank_monitor.get_device_data(self._token, self.device) except requests.exceptions.HTTPError as http_error: if http_error.response.status_code in ( - requests.codes.unauthorized, # pylint: disable=no-member - requests.codes.bad_request, # pylint: disable=no-member + requests.codes.unauthorized, + requests.codes.bad_request, ): _LOGGER.info("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 3ffa2ff4576..39ae0c2fc16 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -13,8 +13,7 @@ from homeassistant.const import ATTR_ID, CONF_API_KEY, CONF_SHOW_ON_MAP, Platfor from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -171,6 +170,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): class TankerkoenigCoordinatorEntity(CoordinatorEntity): """Tankerkoenig base entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict ) -> None: diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 5f10b54f704..a6a79fd2d92 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -43,6 +43,7 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE """Shows if a station is open or closed.""" _attr_device_class = BinarySensorDeviceClass.DOOR + _attr_translation_key = "status" def __init__( self, @@ -53,9 +54,6 @@ class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorE """Initialize the sensor.""" super().__init__(coordinator, station) self._station_id = station["id"] - self._attr_name = ( - f"{station['brand']} {station['street']} {station['houseNumber']} status" - ) self._attr_unique_id = f"{station['id']}_status" if show_on_map: self._attr_extra_state_attributes = { diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 1638a8c3abb..af21ac4b6d6 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -20,7 +20,6 @@ from .const import ( ATTR_STREET, ATTRIBUTION, DOMAIN, - FUEL_TYPES, ) _LOGGER = logging.getLogger(__name__) @@ -59,6 +58,7 @@ 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): @@ -66,8 +66,7 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): super().__init__(coordinator, station) self._station_id = station["id"] self._fuel_type = fuel_type - self._attr_name = f"{station['brand']} {station['street']} {station['houseNumber']} {FUEL_TYPES[fuel_type]}" - self._attr_native_unit_of_measurement = CURRENCY_EURO + self._attr_translation_key = fuel_type self._attr_unique_id = f"{station['id']}_{fuel_type}" attrs = { ATTR_BRAND: station["brand"], diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index dea370f45b3..43d444b2c46 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -43,5 +43,23 @@ } } } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "sensor": { + "e5": { + "name": "Super" + }, + "e10": { + "name": "Super E10" + }, + "diesel": { + "name": "Diesel" + } + } } } diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index f235256f772..220bc4e31fb 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.6.5"] + "requirements": ["HATasmota==0.7.0"] } diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index bfa6d01032b..e99106d09e8 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -16,9 +16,9 @@ from homeassistant.components.mqtt import ( is_connected as mqtt_connected, ) from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .discovery import ( TASMOTA_DISCOVERY_ENTITY_UPDATED, @@ -32,6 +32,8 @@ _LOGGER = logging.getLogger(__name__) class TasmotaEntity(Entity): """Base class for Tasmota entities.""" + _attr_has_entity_name = True + def __init__(self, tasmota_entity: HATasmotaEntity) -> None: """Initialize.""" self._tasmota_entity = tasmota_entity diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index a90d78380b4..b7e62846ac1 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -7,8 +7,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index eeca235ab44..4dfe0a28d01 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -168,7 +168,7 @@ }, "send_animation": { "name": "Send animation", - "description": "Sends an anmiation.", + "description": "Sends an animation.", "fields": { "url": { "name": "[%key:common::config_flow::data::url%]", diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py index 1e7a30d6174..4abe1dfd174 100644 --- a/homeassistant/components/tellduslive/binary_sensor.py +++ b/homeassistant/components/tellduslive/binary_sensor.py @@ -34,6 +34,8 @@ async def async_setup_entry( class TelldusLiveSensor(TelldusLiveEntity, BinarySensorEntity): """Representation of a Tellstick sensor.""" + _attr_name = None + @property def is_on(self): """Return true if switch is on.""" diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index c87b3998a27..060b90a7d70 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -3,7 +3,6 @@ import asyncio import logging import os -import async_timeout from tellduslive import Session, supports_local_api import voluptuous as vol @@ -91,7 +90,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): auth_url = await self.hass.async_add_executor_job(self._get_auth_url) if not auth_url: return self.async_abort(reason="unknown_authorize_url_generation") diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index 4934bf811af..2a32756aa1b 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -35,6 +35,8 @@ async def async_setup_entry( class TelldusLiveCover(TelldusLiveEntity, CoverEntity): """Representation of a cover.""" + _attr_name = None + @property def is_closed(self) -> bool: """Return the current position of the cover.""" diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py index db32d41cede..fdacc02bfca 100644 --- a/homeassistant/components/tellduslive/entry.py +++ b/homeassistant/components/tellduslive/entry.py @@ -9,11 +9,11 @@ from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_MODEL, ATTR_VIA_DEVICE, - DEVICE_DEFAULT_NAME, ) from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import SIGNAL_UPDATE_ENTITY @@ -26,12 +26,12 @@ class TelldusLiveEntity(Entity): """Base class for all Telldus Live entities.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, client, device_id): """Initialize the entity.""" self._id = device_id self._client = client - self._name = self.device.name self._async_unsub_dispatcher_connect = None async def async_added_to_hass(self): @@ -49,8 +49,6 @@ class TelldusLiveEntity(Entity): @callback def _update_callback(self): """Return the property of the device might have changed.""" - if self.device.name: - self._name = self.device.name self.async_write_ha_state() @property @@ -73,11 +71,6 @@ class TelldusLiveEntity(Entity): """Return true if unable to access real state of entity.""" return True - @property - def name(self): - """Return name of device.""" - return self._name or DEVICE_DEFAULT_NAME - @property def available(self): """Return true if device is not offline.""" diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 3b69b58966c..8284b386250 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -37,6 +37,7 @@ async def async_setup_entry( class TelldusLiveLight(TelldusLiveEntity, LightEntity): """Representation of a Tellstick Net light.""" + _attr_name = None _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 2c3ae3588ab..e15f89888b1 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -43,81 +43,75 @@ SENSOR_TYPE_BAROMETRIC_PRESSURE = "barpress" SENSOR_TYPES: dict[str, SensorEntityDescription] = { SENSOR_TYPE_TEMPERATURE: SensorEntityDescription( key=SENSOR_TYPE_TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_HUMIDITY: SensorEntityDescription( key=SENSOR_TYPE_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_RAINRATE: SensorEntityDescription( key=SENSOR_TYPE_RAINRATE, - name="Rain rate", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SENSOR_TYPE_RAINTOTAL: SensorEntityDescription( key=SENSOR_TYPE_RAINTOTAL, - name="Rain total", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, ), SENSOR_TYPE_WINDDIRECTION: SensorEntityDescription( key=SENSOR_TYPE_WINDDIRECTION, - name="Wind direction", + translation_key="wind_direction", ), SENSOR_TYPE_WINDAVERAGE: SensorEntityDescription( key=SENSOR_TYPE_WINDAVERAGE, - name="Wind average", + translation_key="wind_average", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_WINDGUST: SensorEntityDescription( key=SENSOR_TYPE_WINDGUST, - name="Wind gust", + translation_key="wind_gust", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_UV: SensorEntityDescription( key=SENSOR_TYPE_UV, - name="UV", + translation_key="uv", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_WATT: SensorEntityDescription( key=SENSOR_TYPE_WATT, - name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_LUMINANCE: SensorEntityDescription( key=SENSOR_TYPE_LUMINANCE, - name="Luminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_DEW_POINT: SensorEntityDescription( key=SENSOR_TYPE_DEW_POINT, - name="Dew Point", + translation_key="dew_point", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SENSOR_TYPE_BAROMETRIC_PRESSURE: SensorEntityDescription( key=SENSOR_TYPE_BAROMETRIC_PRESSURE, - name="Barometric Pressure", native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), } @@ -150,6 +144,8 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): super().__init__(client, device_id) if desc := SENSOR_TYPES.get(self._type): self.entity_description = desc + else: + self._attr_name = None @property def device_id(self): @@ -181,14 +177,6 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): """Return the value as humidity.""" return int(round(float(self._value))) - @property - def name(self): - """Return the name of the sensor.""" - quantity_name = ( - self.entity_description.name if hasattr(self, "entity_description") else "" - ) - return "{} {}".format(super().name, quantity_name or "").strip() - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 27e74d6d938..1dbea7a0e6c 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -21,5 +21,24 @@ "title": "Pick endpoint." } } + }, + "entity": { + "sensor": { + "wind_direction": { + "name": "Wind direction" + }, + "wind_average": { + "name": "Wind average" + }, + "wind_gust": { + "name": "Wind gust" + }, + "uv": { + "name": "UV" + }, + "dew_point": { + "name": "Dew point" + } + } } } diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index fbecda2e775..5ae5a904689 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -34,6 +34,8 @@ async def async_setup_entry( class TelldusLiveSwitch(TelldusLiveEntity, SwitchEntity): """Representation of a Tellstick switch.""" + _attr_name = None + @property def is_on(self): """Return true if switch is on.""" diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index 47b51853bcd..c4ba7081f5a 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -6,6 +6,7 @@ from collections.abc import Callable import logging from homeassistant import config as conf_util +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_START, @@ -19,11 +20,12 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.script import Script from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration -from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -60,6 +62,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups( + entry, (entry.options["template_type"],) + ) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """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( + entry, (entry.options["template_type"],) + ) + + async def _process_config(hass: HomeAssistant, hass_config: ConfigType) -> None: """Process config.""" coordinators: list[TriggerUpdateCoordinator] | None = hass.data.pop(DOMAIN, None) @@ -111,6 +134,7 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): self.config = config self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None + self._script: Script | None = None @property def unique_id(self) -> str | None: @@ -148,6 +172,14 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): async def _attach_triggers(self, start_event=None) -> None: """Attach the triggers.""" + if CONF_ACTION in self.config: + self._script = Script( + self.hass, + self.config[CONF_ACTION], + self.name, + DOMAIN, + ) + if start_event is not None: self._unsub_start = None @@ -161,8 +193,11 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): start_event is not None, ) - @callback - def _handle_triggered(self, run_variables, context=None): + async def _handle_triggered(self, run_variables, context=None): + if self._script: + script_result = await self._script.async_run(run_variables, context) + if script_result: + run_variables = script_result.variables self.async_set_updated_data( {"run_variables": run_variables, "context": context} ) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 8f164142212..af2e432c61e 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -254,13 +254,14 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): ) self._state = None - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" if self._template: self.add_template_attribute( "_state", self._template, None, self._update_state ) - await super().async_added_to_hass() + super()._async_setup_templates() async def _async_alarm_arm(self, state, script, code): """Arm the panel to specified state with supplied script.""" diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 61df78307f0..ca0ed583d86 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -194,6 +195,29 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + _options = dict(config_entry.options) + _options.pop("template_type") + validated_config = BINARY_SENSOR_SCHEMA(_options) + async_add_entities( + [BinarySensorTemplate(hass, validated_config, config_entry.entry_id)] + ) + + +@callback +def async_create_preview_binary_sensor( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> BinarySensorTemplate: + """Create a preview sensor.""" + validated_config = BINARY_SENSOR_SCHEMA(config | {CONF_NAME: name}) + return BinarySensorTemplate(hass, validated_config, None) + + class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" @@ -224,14 +248,18 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): self._delay_off_raw = config.get(CONF_DELAY_OFF) async def async_added_to_hass(self) -> None: - """Restore state and register callbacks.""" + """Restore state.""" if ( (self._delay_on_raw is not None or self._delay_off_raw is not None) and (last_state := await self.async_get_last_state()) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): self._state = last_state.state == STATE_ON + await super().async_added_to_hass() + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" self.add_template_attribute("_state", self._template, None, self._update_state) if self._delay_on_raw is not None: @@ -250,7 +278,7 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): "_delay_off", self._delay_off_raw, cv.positive_time_period ) - await super().async_added_to_hass() + super()._async_setup_templates() @callback def _update_state(self, result): diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 2261bde2659..54c82d88c74 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -22,7 +22,7 @@ from . import ( select as select_platform, sensor as sensor_platform, ) -from .const import CONF_TRIGGER, DOMAIN +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -30,6 +30,7 @@ CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(NUMBER_DOMAIN): vol.All( cv.ensure_list, [number_platform.NUMBER_SCHEMA] ), diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py new file mode 100644 index 00000000000..c361b4c42cc --- /dev/null +++ b/homeassistant/components/template/config_flow.py @@ -0,0 +1,397 @@ +"""Config flow for the Template integration.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine, Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASS_STATE_CLASSES, + DEVICE_CLASS_UNITS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_STATE, + CONF_UNIT_OF_MEASUREMENT, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, +) + +from .binary_sensor import async_create_preview_binary_sensor +from .const import DOMAIN +from .sensor import async_create_preview_sensor +from .template_entity import TemplateEntity + +NONE_SENTINEL = "none" + + +def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: + """Generate schema.""" + schema: dict[vol.Marker, Any] = {} + + if domain == Platform.BINARY_SENSOR and flow_type == "config": + schema = { + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + NONE_SENTINEL, + *sorted( + [cls.value for cls in BinarySensorDeviceClass], + key=str.casefold, + ), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + ), + ) + } + + if domain == Platform.SENSOR: + schema = { + vol.Optional( + CONF_UNIT_OF_MEASUREMENT, default=NONE_SENTINEL + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + NONE_SENTINEL, + *sorted( + { + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + }, + key=str.casefold, + ), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="sensor_unit_of_measurement", + custom_value=True, + ), + ), + vol.Optional( + CONF_DEVICE_CLASS, default=NONE_SENTINEL + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + NONE_SENTINEL, + *sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + key=str.casefold, + ), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="sensor_device_class", + ), + ), + vol.Optional( + CONF_STATE_CLASS, default=NONE_SENTINEL + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + NONE_SENTINEL, + *sorted([cls.value for cls in SensorStateClass]), + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="sensor_state_class", + ), + ), + } + + return schema + + +def options_schema(domain: str) -> vol.Schema: + """Generate options schema.""" + return vol.Schema( + {vol.Required(CONF_STATE): selector.TemplateSelector()} + | generate_schema(domain, "option"), + ) + + +def config_schema(domain: str) -> vol.Schema: + """Generate config schema.""" + return vol.Schema( + { + vol.Required(CONF_NAME): selector.TextSelector(), + vol.Required(CONF_STATE): selector.TemplateSelector(), + } + | generate_schema(domain, "config"), + ) + + +async def choose_options_step(options: dict[str, Any]) -> str: + """Return next step_id for options flow according to template_type.""" + return cast(str, options["template_type"]) + + +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if key not in options: + continue + if options[key] == NONE_SENTINEL: + options.pop(key) + + +def _validate_unit(options: dict[str, Any]) -> None: + """Validate unit of measurement.""" + if ( + (device_class := options.get(CONF_DEVICE_CLASS)) + and (units := DEVICE_CLASS_UNITS.get(device_class)) is not None + and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units + ): + sorted_units = sorted( + [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], + key=str.casefold, + ) + if len(sorted_units) == 1: + units_string = sorted_units[0] + else: + units_string = f"one of {', '.join(sorted_units)}" + + raise vol.Invalid( + f"'{unit}' is not a valid unit for device class '{device_class}'; " + f"expected {units_string}" + ) + + +def _validate_state_class(options: dict[str, Any]) -> None: + """Validate state class.""" + if ( + (state_class := options.get(CONF_STATE_CLASS)) + and (device_class := options.get(CONF_DEVICE_CLASS)) + and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None + and state_class not in state_classes + ): + sorted_state_classes = sorted( + [f"'{str(state_class)}'" for state_class in state_classes], + key=str.casefold, + ) + if len(sorted_state_classes) == 0: + state_classes_string = "no state class" + elif len(sorted_state_classes) == 1: + state_classes_string = sorted_state_classes[0] + else: + state_classes_string = f"one of {', '.join(sorted_state_classes)}" + + raise vol.Invalid( + f"'{state_class}' is not a valid state class for device class " + f"'{device_class}'; expected {state_classes_string}" + ) + + +def validate_user_input( + template_type: str, +) -> Callable[ + [SchemaCommonFlowHandler, dict[str, Any]], + Coroutine[Any, Any, dict[str, Any]], +]: + """Do post validation of user input. + + For binary sensors: Strip none-sentinels. + For sensors: Strip none-sentinels and validate unit of measurement. + For all domaines: Set template type. + """ + + async def _validate_user_input( + _: SchemaCommonFlowHandler, + user_input: dict[str, Any], + ) -> dict[str, Any]: + """Add template type to user input.""" + if template_type in (Platform.BINARY_SENSOR, Platform.SENSOR): + _strip_sentinel(user_input) + if template_type == Platform.SENSOR: + _validate_unit(user_input) + _validate_state_class(user_input) + return {"template_type": template_type} | user_input + + return _validate_user_input + + +TEMPLATE_TYPES = [ + "binary_sensor", + "sensor", +] + +CONFIG_FLOW = { + "user": SchemaFlowMenuStep(TEMPLATE_TYPES), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + config_schema(Platform.BINARY_SENSOR), + preview="template", + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + config_schema(Platform.SENSOR), + preview="template", + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(next_step=choose_options_step), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + options_schema(Platform.BINARY_SENSOR), + preview="template", + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + options_schema(Platform.SENSOR), + preview="template", + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + +CREATE_PREVIEW_ENTITY: dict[ + str, + Callable[[HomeAssistant, str, dict[str, Any]], TemplateEntity], +] = { + "binary_sensor": async_create_preview_binary_sensor, + "sensor": async_create_preview_sensor, +} + + +class TemplateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle config flow for template helper.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + @callback + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) + + @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"): "template/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@callback +def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + + def _validate(schema: vol.Schema, domain: str, user_input: dict[str, Any]) -> Any: + errors = {} + key: vol.Marker + for key, validator in schema.schema.items(): + if key.schema not in user_input: + continue + try: + validator(user_input[key.schema]) + except vol.Invalid as ex: + errors[key.schema] = str(ex.msg) + + if domain == Platform.SENSOR: + _strip_sentinel(user_input) + try: + _validate_unit(user_input) + except vol.Invalid as ex: + errors[CONF_UNIT_OF_MEASUREMENT] = str(ex.msg) + try: + _validate_state_class(user_input) + except vol.Invalid as ex: + errors[CONF_STATE_CLASS] = str(ex.msg) + + return errors + + entity_registry_entry: er.RegistryEntry | None = None + if msg["flow_type"] == "config_flow": + flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) + template_type = flow_status["step_id"] + form_step = cast(SchemaFlowFormStep, CONFIG_FLOW[template_type]) + schema = cast(vol.Schema, form_step.schema) + name = msg["user_input"]["name"] + else: + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + if not config_entry: + raise HomeAssistantError + template_type = config_entry.options["template_type"] + name = config_entry.options["name"] + schema = cast(vol.Schema, OPTIONS_FLOW[template_type].schema) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, flow_status["handler"] + ) + if entries: + entity_registry_entry = entries[0] + + errors = _validate(schema, template_type, msg["user_input"]) + + @callback + def async_preview_updated( + state: str | None, + attributes: Mapping[str, Any] | None, + listeners: dict[str, bool | set[str]] | None, + error: str | None, + ) -> None: + """Forward config entry state events to websocket.""" + if error is not None: + connection.send_message( + websocket_api.event_message( + msg["id"], + {"error": error}, + ) + ) + return + connection.send_message( + websocket_api.event_message( + msg["id"], + {"attributes": attributes, "listeners": listeners, "state": state}, + ) + ) + + if errors: + connection.send_message( + { + "id": msg["id"], + "type": websocket_api.const.TYPE_RESULT, + "success": False, + "error": {"code": "invalid_user_input", "message": errors}, + } + ) + return + + _strip_sentinel(msg["user_input"]) + preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) + preview_entity.hass = hass + preview_entity.registry_entry = entity_registry_entry + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( + async_preview_updated + ) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 9b371125750..6805c0ad812 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -2,6 +2,7 @@ from homeassistant.const import Platform +CONF_ACTION = "action" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_TRIGGER = "trigger" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 256773b714b..3a8e536f7f5 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -182,8 +182,9 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._is_closing = False self._tilt_value = None - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" if self._template: self.add_template_attribute( "_position", self._template, None, self._update_state @@ -204,7 +205,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._update_tilt, none_on_template_error=True, ) - await super().async_added_to_hass() + super()._async_setup_templates() @callback def _update_state(self, result): diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 88309810ad2..c07c680887b 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -351,8 +351,9 @@ class TemplateFan(TemplateEntity, FanEntity): self._state = False - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" if self._template: self.add_template_attribute( "_state", self._template, None, self._update_state @@ -390,7 +391,7 @@ class TemplateFan(TemplateEntity, FanEntity): self._update_direction, none_on_template_error=True, ) - await super().async_added_to_hass() + super()._async_setup_templates() @callback def _update_percentage(self, percentage): diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 95bbac576ad..55a0e2fb72d 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -6,10 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.components.image import ( - DOMAIN as IMAGE_DOMAIN, - ImageEntity, -) +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity from homeassistant.const import CONF_UNIQUE_ID, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError @@ -20,10 +17,7 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE -from .template_entity import ( - TemplateEntity, - make_template_entity_common_schema, -) +from .template_entity import TemplateEntity, make_template_entity_common_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -114,10 +108,11 @@ class StateImageEntity(TemplateEntity, ImageEntity): self._cached_image = None self._attr_image_url = result - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" self.add_template_attribute("_url", self._url_template, None, self._update_url) - await super().async_added_to_hass() + super()._async_setup_templates() class TriggerImageEntity(TriggerEntity, ImageEntity): diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index b403034208a..09f5054ed51 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -268,8 +268,9 @@ class LightTemplate(TemplateEntity, LightEntity): """Return true if device is on.""" return self._state - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" if self._template: self.add_template_attribute( "_state", self._template, None, self._update_state @@ -338,7 +339,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._update_supports_transition, none_on_template_error=True, ) - await super().async_added_to_hass() + super()._async_setup_templates() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" @@ -459,9 +460,8 @@ class LightTemplate(TemplateEntity, LightEntity): ) self._brightness = None except ValueError: - _LOGGER.error( - "Template must supply an integer brightness from 0-255, or 'None'", - exc_info=True, + _LOGGER.exception( + "Template must supply an integer brightness from 0-255, or 'None'" ) self._brightness = None @@ -559,12 +559,9 @@ class LightTemplate(TemplateEntity, LightEntity): ) self._temperature = None except ValueError: - _LOGGER.error( - ( - "Template must supply an integer temperature within the range for" - " this light, or 'None'" - ), - exc_info=True, + _LOGGER.exception( + "Template must supply an integer temperature within the range for" + " this light, or 'None'" ) self._temperature = None @@ -620,12 +617,9 @@ class LightTemplate(TemplateEntity, LightEntity): return self._max_mireds = int(render) except ValueError: - _LOGGER.error( - ( - "Template must supply an integer temperature within the range for" - " this light, or 'None'" - ), - exc_info=True, + _LOGGER.exception( + "Template must supply an integer temperature within the range for" + " this light, or 'None'" ) self._max_mireds = None @@ -638,12 +632,9 @@ class LightTemplate(TemplateEntity, LightEntity): return self._min_mireds = int(render) except ValueError: - _LOGGER.error( - ( - "Template must supply an integer temperature within the range for" - " this light, or 'None'" - ), - exc_info=True, + _LOGGER.exception( + "Template must supply an integer temperature within the range for" + " this light, or 'None'" ) self._min_mireds = None diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index da8be80d8a4..d8c7127f0e6 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -133,12 +133,13 @@ class TemplateLock(TemplateEntity, LockEntity): self._state = None - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" self.add_template_attribute( "_state", self._state_template, None, self._update_state ) - await super().async_added_to_hass() + super()._async_setup_templates() async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index 6fe6bfb9db4..4112ca7a73f 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -3,7 +3,9 @@ "name": "Template", "after_dependencies": ["group"], "codeowners": ["@PhracturedBlue", "@tetienne", "@home-assistant/core"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/template", + "integration_type": "helper", "iot_class": "local_push", "quality_scale": "internal" } diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 4e74b469984..988cebf08ab 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import ( NumberEntity, ) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script @@ -124,8 +124,9 @@ class TemplateNumber(TemplateEntity, NumberEntity): self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" self.add_template_attribute( "_attr_native_value", self._value_template, @@ -152,7 +153,7 @@ class TemplateNumber(TemplateEntity, NumberEntity): validator=vol.Coerce(float), none_on_template_error=True, ) - await super().async_added_to_hass() + super()._async_setup_templates() async def async_set_native_value(self, value: float) -> None: """Set value of the number.""" diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 7871410a694..fea972a5d6f 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -13,7 +13,7 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script @@ -114,8 +114,9 @@ class TemplateSelect(TemplateEntity, SelectEntity): self._attr_options = [] self._attr_current_option = None - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" self.add_template_attribute( "_attr_current_option", self._value_template, @@ -128,7 +129,7 @@ class TemplateSelect(TemplateEntity, SelectEntity): validator=vol.All(cv.ensure_list, [cv.string]), none_on_template_error=True, ) - await super().async_added_to_hass() + super()._async_setup_templates() async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 7a5df84a207..cdd14921bc1 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -14,8 +14,10 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, RestoreSensor, SensorDeviceClass, + SensorEntity, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, @@ -37,10 +39,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.template_entity import ( - TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, -) +from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -51,6 +50,7 @@ from .const import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -196,7 +196,29 @@ async def async_setup_platform( ) -class SensorTemplate(TemplateSensor): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + _options = dict(config_entry.options) + _options.pop("template_type") + validated_config = SENSOR_SCHEMA(_options) + async_add_entities([SensorTemplate(hass, validated_config, config_entry.entry_id)]) + + +@callback +def async_create_preview_sensor( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> SensorTemplate: + """Create a preview sensor.""" + validated_config = SENSOR_SCHEMA(config | {CONF_NAME: name}) + entity = SensorTemplate(hass, validated_config, None) + return entity + + +class SensorTemplate(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False @@ -209,19 +231,23 @@ class SensorTemplate(TemplateSensor): ) -> None: """Initialize the sensor.""" super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + 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] 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 ) - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" self.add_template_attribute( "_attr_native_value", self._template, None, self._update_state ) - await super().async_added_to_hass() + super()._async_setup_templates() @callback def _update_state(self, result): diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index fce7129353e..a0ee31126cd 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,4 +1,154 @@ { + "config": { + "step": { + "binary_sensor": { + "data": { + "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "name": "[%key:common::config_flow::data::name%]", + "state": "[%key:component::template::config::step::sensor::data::state%]" + }, + "title": "Template binary sensor" + }, + "sensor": { + "data": { + "device_class": "Device class", + "name": "[%key:common::config_flow::data::name%]", + "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", + "state": "State template", + "unit_of_measurement": "Unit of measurement" + }, + "title": "Template sensor" + }, + "user": { + "description": "This helper allow you to create helper entities that define their state using a template.", + "menu_options": { + "binary_sensor": "Template a binary sensor", + "sensor": "Template a sensor" + }, + "title": "Template helper" + } + } + }, + "options": { + "step": { + "binary_sensor": { + "data": { + "state": "[%key:component::template::config::step::sensor::data::state%]" + }, + "title": "[%key:component::template::config::step::binary_sensor::title%]" + }, + "sensor": { + "data": { + "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", + "state_class": "[%key:component::template::config::step::sensor::data::state_class%]", + "state": "[%key:component::template::config::step::sensor::data::state%]", + "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" + }, + "title": "[%key:component::template::config::step::sensor::title%]" + } + } + }, + "selector": { + "binary_sensor_device_class": { + "options": { + "none": "[%key:component::template::selector::sensor_device_class::options::none%]", + "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", + "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]", + "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]", + "cold": "[%key:component::binary_sensor::entity_component::cold::name%]", + "connectivity": "[%key:component::binary_sensor::entity_component::connectivity::name%]", + "door": "[%key:component::binary_sensor::entity_component::door::name%]", + "garage_door": "[%key:component::binary_sensor::entity_component::garage_door::name%]", + "gas": "[%key:component::binary_sensor::entity_component::gas::name%]", + "heat": "[%key:component::binary_sensor::entity_component::heat::name%]", + "light": "[%key:component::binary_sensor::entity_component::light::name%]", + "lock": "[%key:component::binary_sensor::entity_component::lock::name%]", + "moisture": "[%key:component::binary_sensor::entity_component::moisture::name%]", + "motion": "[%key:component::binary_sensor::entity_component::motion::name%]", + "moving": "[%key:component::binary_sensor::entity_component::moving::name%]", + "occupancy": "[%key:component::binary_sensor::entity_component::occupancy::name%]", + "opening": "[%key:component::binary_sensor::entity_component::opening::name%]", + "plug": "[%key:component::binary_sensor::entity_component::plug::name%]", + "power": "[%key:component::binary_sensor::entity_component::power::name%]", + "presence": "[%key:component::binary_sensor::entity_component::presence::name%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::name%]", + "running": "[%key:component::binary_sensor::entity_component::running::name%]", + "safety": "[%key:component::binary_sensor::entity_component::safety::name%]", + "smoke": "[%key:component::binary_sensor::entity_component::smoke::name%]", + "sound": "[%key:component::binary_sensor::entity_component::sound::name%]", + "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%]" + } + }, + "sensor_device_class": { + "options": { + "none": "No device class", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "date": "[%key:component::sensor::entity_component::date::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "sensor_state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "sensor_unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } + }, "services": { "reload": { "name": "[%key:common::action::reload%]", diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index b21e02e4074..39270d3fc6d 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -138,14 +138,17 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): await super().async_added_to_hass() if state := await self.async_get_last_state(): self._state = state.state == STATE_ON + await super().async_added_to_hass() - # no need to listen for events - else: + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template is not None: self.add_template_attribute( "_state", self._template, None, self._update_state ) - await super().async_added_to_hass() + super()._async_setup_templates() @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 0d6d5a99748..8c3554c067e 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -1,7 +1,10 @@ """TemplateEntity utility class.""" from __future__ import annotations +from collections.abc import Callable, Mapping +import contextlib import itertools +import logging from typing import Any import voluptuous as vol @@ -12,14 +15,38 @@ from homeassistant.const import ( CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + STATE_UNKNOWN, ) +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HomeAssistant, + State, + callback, + validate_state, +) +from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( # noqa: F401 pylint: disable=unused-import +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import ( + EventStateChangedData, + TrackTemplate, + TrackTemplateResult, + TrackTemplateResultInfo, + async_track_template_result, +) +from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.template import ( + Template, + TemplateStateFromEntityId, + result_as_boolean, +) +from homeassistant.helpers.trigger_template_entity import ( TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, make_template_entity_base_schema, ) +from homeassistant.helpers.typing import ConfigType, EventType from .const import ( CONF_ATTRIBUTE_TEMPLATES, @@ -29,6 +56,8 @@ from .const import ( CONF_PICTURE, ) +_LOGGER = logging.getLogger(__name__) + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema( { vol.Optional(CONF_AVAILABILITY): cv.template, @@ -113,3 +142,419 @@ def rewrite_common_legacy_to_modern_conf( entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME]) return entity_cfg + + +class _TemplateAttribute: + """Attribute value linked to template result.""" + + def __init__( + self, + entity: Entity, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool | None = False, + ) -> None: + """Template attribute.""" + self._entity = entity + self._attribute = attribute + self.template = template + self.validator = validator + self.on_update = on_update + self.async_update = None + self.none_on_template_error = none_on_template_error + + @callback + def async_setup(self) -> None: + """Config update path for the attribute.""" + if self.on_update: + return + + if not hasattr(self._entity, self._attribute): + raise AttributeError(f"Attribute '{self._attribute}' does not exist.") + + self.on_update = self._default_update + + @callback + def _default_update(self, result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + setattr(self._entity, self._attribute, attr_result) + + @callback + def handle_result( + self, + event: EventType[EventStateChangedData] | None, + template: Template, + last_result: str | None | TemplateError, + result: str | TemplateError, + ) -> None: + """Handle a template result event callback.""" + if isinstance(result, TemplateError): + _LOGGER.error( + ( + "TemplateError('%s') " + "while processing template '%s' " + "for attribute '%s' in entity '%s'" + ), + result, + self.template, + self._attribute, + self._entity.entity_id, + ) + if self.none_on_template_error: + self._default_update(result) + else: + assert self.on_update + self.on_update(result) + return + + if not self.validator: + assert self.on_update + self.on_update(result) + return + + try: + validated = self.validator(result) + except vol.Invalid as ex: + _LOGGER.error( + ( + "Error validating template result '%s' " + "from template '%s' " + "for attribute '%s' in entity %s " + "validation message '%s'" + ), + result, + self.template, + self._attribute, + self._entity.entity_id, + ex.msg, + ) + assert self.on_update + self.on_update(None) + return + + assert self.on_update + self.on_update(validated) + return + + +class TemplateEntity(Entity): + """Entity that uses templates to calculate attributes.""" + + _attr_available = True + _attr_entity_picture = None + _attr_icon = None + + def __init__( + self, + hass: HomeAssistant, + *, + availability_template: Template | None = None, + icon_template: Template | None = None, + entity_picture_template: Template | None = None, + attribute_templates: dict[str, Template] | None = None, + config: ConfigType | None = None, + fallback_name: str | None = None, + unique_id: str | None = None, + ) -> None: + """Template Entity.""" + self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} + self._template_result_info: TrackTemplateResultInfo | None = None + self._attr_extra_state_attributes = {} + self._self_ref_update_count = 0 + self._attr_unique_id = unique_id + self._preview_callback: Callable[ + [ + str | None, + dict[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, + ] | None = None + if config is None: + self._attribute_templates = attribute_templates + self._availability_template = availability_template + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + self._friendly_name_template = None + else: + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + + class DummyState(State): + """None-state for template entities not yet added to the state machine.""" + + def __init__(self) -> None: + """Initialize a new state.""" + super().__init__("unknown.unknown", STATE_UNKNOWN) + self.entity_id = None # type: ignore[assignment] + + @property + def name(self) -> str: + """Name of this state.""" + return "" + + variables = {"this": DummyState()} + + # Try to render the name as it can influence the entity ID + self._attr_name = fallback_name + if self._friendly_name_template: + self._friendly_name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = self._friendly_name_template.async_render( + variables=variables, parse_result=False + ) + + # Templates will not render while the entity is unavailable, try to render the + # icon and picture templates. + if self._entity_picture_template: + self._entity_picture_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_entity_picture = self._entity_picture_template.async_render( + variables=variables, parse_result=False + ) + + if self._icon_template: + self._icon_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_icon = self._icon_template.async_render( + variables=variables, parse_result=False + ) + + @callback + def _update_available(self, result: str | TemplateError) -> None: + if isinstance(result, TemplateError): + self._attr_available = True + return + + self._attr_available = result_as_boolean(result) + + @callback + def _update_state(self, result: str | TemplateError) -> None: + if self._availability_template: + return + + self._attr_available = not isinstance(result, TemplateError) + + @callback + def _add_attribute_template( + self, attribute_key: str, attribute_template: Template + ) -> None: + """Create a template tracker for the attribute.""" + + def _update_attribute(result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + self._attr_extra_state_attributes[attribute_key] = attr_result + + self.add_template_attribute( + attribute_key, attribute_template, None, _update_attribute + ) + + def add_template_attribute( + self, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool = False, + ) -> None: + """Call in the constructor to add a template linked to a attribute. + + Parameters + ---------- + attribute + The name of the attribute to link to. This attribute must exist + unless a custom on_update method is supplied. + template + The template to calculate. + validator + Validator function to parse the result and ensure it's valid. + on_update + Called to store the template result rather than storing it + the supplied attribute. Passed the result of the validator, or None + if the template or validator resulted in an error. + none_on_template_error + If True, the attribute will be set to None if the template errors. + + """ + assert self.hass is not None, "hass cannot be None" + template.hass = self.hass + template_attribute = _TemplateAttribute( + self, attribute, template, validator, on_update, none_on_template_error + ) + self._template_attrs.setdefault(template, []) + self._template_attrs[template].append(template_attribute) + + @callback + def _handle_results( + self, + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + """Call back the results to the attributes.""" + if event: + self.async_set_context(event.context) + + entity_id = event and event.data["entity_id"] + + if entity_id and entity_id == self.entity_id: + self._self_ref_update_count += 1 + else: + self._self_ref_update_count = 0 + + if self._self_ref_update_count > len(self._template_attrs): + for update in updates: + _LOGGER.warning( + ( + "Template loop detected while processing event: %s, skipping" + " template render for Template[%s]" + ), + event, + update.template.template, + ) + return + + for update in updates: + for template_attr in self._template_attrs[update.template]: + template_attr.handle_result( + event, update.template, update.last_result, update.result + ) + + if not self._preview_callback: + self.async_write_ha_state() + return + + try: + state, attrs = self._async_generate_attributes() + validate_state(state) + except Exception as err: # pylint: disable=broad-exception-caught + self._preview_callback(None, None, None, str(err)) + else: + assert self._template_result_info + self._preview_callback( + state, attrs, self._template_result_info.listeners, None + ) + + @callback + def _async_template_startup( + self, + _hass: HomeAssistant | None, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: + template_var_tups: list[TrackTemplate] = [] + has_availability_template = False + + variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} + + for template, attributes in self._template_attrs.items(): + template_var_tup = TrackTemplate(template, variables) + is_availability_template = False + for attribute in attributes: + # pylint: disable-next=protected-access + if attribute._attribute == "_attr_available": + has_availability_template = True + is_availability_template = True + attribute.async_setup() + # Insert the availability template first in the list + if is_availability_template: + template_var_tups.insert(0, template_var_tup) + else: + template_var_tups.append(template_var_tup) + + result_info = async_track_template_result( + self.hass, + template_var_tups, + self._handle_results, + log_fn=log_fn, + has_super_template=has_availability_template, + ) + self.async_on_remove(result_info.async_remove) + self._template_result_info = result_info + result_info.async_refresh() + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._availability_template is not None: + self.add_template_attribute( + "_attr_available", + self._availability_template, + None, + self._update_available, + ) + if self._attribute_templates is not None: + for key, value in self._attribute_templates.items(): + self._add_attribute_template(key, value) + if self._icon_template is not None: + self.add_template_attribute( + "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) + ) + if self._entity_picture_template is not None: + self.add_template_attribute( + "_attr_entity_picture", self._entity_picture_template + ) + if ( + self._friendly_name_template is not None + and not self._friendly_name_template.is_static + ): + self.add_template_attribute("_attr_name", self._friendly_name_template) + + @callback + def async_start_preview( + self, + preview_callback: Callable[ + [ + str | None, + Mapping[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, + ], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + def log_template_error(level: int, msg: str) -> None: + preview_callback(None, None, None, msg) + + self._preview_callback = preview_callback + self._async_setup_templates() + try: + self._async_template_startup(None, log_template_error) + except Exception as err: # pylint: disable=broad-exception-caught + preview_callback(None, None, None, str(err)) + return self._call_on_remove_callbacks + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._async_setup_templates() + + async_at_start(self.hass, self._async_template_startup) + + async def async_update(self) -> None: + """Call for forced update.""" + assert self._template_result_info + self._template_result_info.async_refresh() + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **run_variables, + }, + context=context, + ) diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py index 113da3aa3ee..327c988106e 100644 --- a/homeassistant/components/template/trigger.py +++ b/homeassistant/components/template/trigger.py @@ -80,7 +80,6 @@ async def async_attach_trigger( return if delay_cancel: - # pylint: disable-next=not-callable delay_cancel() delay_cancel = None @@ -156,7 +155,6 @@ async def async_attach_trigger( """Remove state listeners async.""" unsub() if delay_cancel: - # pylint: disable-next=not-callable delay_cancel() return async_remove diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 7d1a844fb3d..ca2f7240086 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.template_entity import TriggerBaseEntity +from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index c5705c34076..4b693c8070c 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -264,8 +264,9 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._attr_fan_speed_list, ) - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" if self._template is not None: self.add_template_attribute( "_state", self._template, None, self._update_state @@ -285,7 +286,7 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._update_battery_level, none_on_template_error=True, ) - await super().async_added_to_hass() + super()._async_setup_templates() @callback def _update_state(self, result): diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 81a6badfc34..a04fc7a641d 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,6 +1,9 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from functools import partial +from typing import Any, Literal + import voluptuous as vol from homeassistant.components.weather import ( @@ -22,9 +25,11 @@ from homeassistant.components.weather import ( ENTITY_ID_FORMAT, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id @@ -39,6 +44,8 @@ from homeassistant.util.unit_conversion import ( from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +CHECK_FORECAST_KEYS = set().union(Forecast.__annotations__.keys()) + CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -68,6 +75,9 @@ CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_TEMPLATE = "forecast_template" +CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" +CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" +CONF_FORECAST_TWICE_DAILY_TEMPLATE = "forecast_twice_daily_template" CONF_PRESSURE_UNIT = "pressure_unit" CONF_WIND_SPEED_UNIT = "wind_speed_unit" CONF_VISIBILITY_UNIT = "visibility_unit" @@ -77,30 +87,40 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, - vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_FORECAST_TEMPLATE), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( + TemperatureConverter.VALID_UNITS + ), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In( + DistanceConverter.VALID_UNITS + ), + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + } + ), ) @@ -151,6 +171,11 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._ozone_template = config.get(CONF_OZONE_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_template = config.get(CONF_FORECAST_TEMPLATE) + self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) + self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) + self._forecast_twice_daily_template = config.get( + CONF_FORECAST_TWICE_DAILY_TEMPLATE + ) self._wind_gust_speed_template = config.get(CONF_WIND_GUST_SPEED_TEMPLATE) self._cloud_coverage_template = config.get(CONF_CLOUD_COVERAGE_TEMPLATE) self._dew_point_template = config.get(CONF_DEW_POINT_TEMPLATE) @@ -180,6 +205,17 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._dew_point = None self._apparent_temperature = None self._forecast: list[Forecast] = [] + self._forecast_daily: list[Forecast] = [] + self._forecast_hourly: list[Forecast] = [] + self._forecast_twice_daily: list[Forecast] = [] + + self._attr_supported_features = 0 + if self._forecast_daily_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if self._forecast_hourly_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if self._forecast_twice_daily_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY @property def condition(self) -> str | None: @@ -246,6 +282,18 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Return the forecast.""" return self._forecast + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_daily + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_hourly + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_twice_daily + @property def attribution(self) -> str | None: """Return the attribution.""" @@ -253,8 +301,9 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): return "Powered by Home Assistant" return self._attribution - async def async_added_to_hass(self) -> None: - """Register callbacks.""" + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" if self._condition_template: self.add_template_attribute( @@ -327,4 +376,73 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): "_forecast", self._forecast_template, ) - await super().async_added_to_hass() + + if self._forecast_daily_template: + self.add_template_attribute( + "_forecast_daily", + self._forecast_daily_template, + on_update=partial(self._update_forecast, "daily"), + validator=partial(self._validate_forecast, "daily"), + ) + if self._forecast_hourly_template: + self.add_template_attribute( + "_forecast_hourly", + self._forecast_hourly_template, + on_update=partial(self._update_forecast, "hourly"), + validator=partial(self._validate_forecast, "hourly"), + ) + if self._forecast_twice_daily_template: + self.add_template_attribute( + "_forecast_twice_daily", + self._forecast_twice_daily_template, + on_update=partial(self._update_forecast, "twice_daily"), + validator=partial(self._validate_forecast, "twice_daily"), + ) + + super()._async_setup_templates() + + @callback + def _update_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + result: list[Forecast] | TemplateError, + ) -> None: + """Save template result and trigger forecast listener.""" + attr_result = None if isinstance(result, TemplateError) else result + setattr(self, f"_forecast_{forecast_type}", attr_result) + self.hass.create_task(self.async_update_listeners([forecast_type])) + + @callback + def _validate_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + result: Any, + ) -> list[Forecast] | None: + """Validate the forecasts.""" + if result is None: + return None + + if not isinstance(result, list): + raise vol.Invalid( + "Forecasts is not a list, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + for forecast in result: + if not isinstance(forecast, dict): + raise vol.Invalid( + "Forecast in list is not a dict, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + diff_result = set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS) + if diff_result: + raise vol.Invalid( + "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if forecast_type == "twice_daily" and "is_daytime" not in forecast: + raise vol.Invalid( + "`is_daytime` is missing in twice_daily forecast, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if "datetime" not in forecast: + raise vol.Invalid( + "`datetime` is required in forecasts, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + continue + return result diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index a149ea92371..e2fce4b94c2 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -9,7 +9,7 @@ import time import numpy as np from PIL import Image, ImageDraw, UnidentifiedImageError -import tensorflow as tf # pylint: disable=import-error +import tensorflow as tf import voluptuous as vol from homeassistant.components.image_processing import ( @@ -148,7 +148,7 @@ def setup_platform( try: # Display warning that PIL will be used if no OpenCV is found. - import cv2 # noqa: F401 pylint: disable=unused-import, import-outside-toplevel + import cv2 # noqa: F401 pylint: disable=import-outside-toplevel except ImportError: _LOGGER.warning( "No OpenCV library found. TensorFlow will process image with " diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 2c2d0ca154b..41403ab84f2 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index e14bd944d36..06005d7e4ed 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -7,7 +7,6 @@ import logging import aiohttp from aiohttp.hdrs import ACCEPT, AUTHORIZATION -import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -134,7 +133,7 @@ class TtnDataStorage: """Get the current state from The Things Network Data Storage.""" try: session = async_get_clientsession(self._hass) - async with async_timeout.timeout(DEFAULT_TIMEOUT): + async with asyncio.timeout(DEFAULT_TIMEOUT): response = await session.get(self._url, headers=self._headers) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 55623f7e3a4..f814fbffbd0 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -19,7 +19,7 @@ from homeassistant.util import dt as dt_util, ulid as ulid_util DATA_STORE = "thread.datasets" STORAGE_KEY = "thread.datasets" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) @@ -33,6 +33,7 @@ class DatasetPreferredError(HomeAssistantError): class DatasetEntry: """Dataset store entry.""" + preferred_border_agent_id: str | None source: str tlv: str @@ -73,6 +74,7 @@ class DatasetEntry: return { "created": self.created.isoformat(), "id": self.id, + "preferred_border_agent_id": self.preferred_border_agent_id, "source": self.source, "tlv": self.tlv, } @@ -86,7 +88,9 @@ class DatasetStoreStore(Store): ) -> dict[str, Any]: """Migrate to the new version.""" if old_major_version == 1: + data = old_data if old_minor_version < 2: + # Deduplicate datasets datasets: dict[str, DatasetEntry] = {} preferred_dataset = old_data["preferred_dataset"] @@ -95,6 +99,7 @@ class DatasetStoreStore(Store): entry = DatasetEntry( created=created, id=dataset["id"], + preferred_border_agent_id=None, source=dataset["source"], tlv=dataset["tlv"], ) @@ -156,6 +161,10 @@ 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 + for dataset in data["datasets"]: + dataset.setdefault("preferred_border_agent_id", None) return data @@ -177,7 +186,9 @@ class DatasetStore: ) @callback - def async_add(self, source: str, tlv: str) -> None: + def async_add( + self, source: str, tlv: str, preferred_border_agent_id: str | None + ) -> None: """Add dataset, does nothing if it already exists.""" # Make sure the tlv is valid dataset = tlv_parser.parse_tlv(tlv) @@ -191,8 +202,17 @@ class DatasetStore: raise HomeAssistantError("Invalid dataset") # Bail out if the dataset already exists - if any(entry for entry in self.datasets.values() if entry.dataset == dataset): - return + 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 + ): + self.async_set_preferred_border_agent_id( + entry.id, preferred_border_agent_id + ) + return # Update if dataset with same extended pan id exists and the timestamp # is newer @@ -237,9 +257,15 @@ 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 + ) return - entry = DatasetEntry(source=source, tlv=tlv) + entry = DatasetEntry( + preferred_border_agent_id=preferred_border_agent_id, source=source, tlv=tlv + ) self.datasets[entry.id] = entry # Set to preferred if there is no preferred dataset if self._preferred_dataset is None: @@ -259,6 +285,16 @@ class DatasetStore: """Get dataset by id.""" return self.datasets.get(dataset_id) + @callback + def async_set_preferred_border_agent_id( + self, dataset_id: str, border_agent_id: str + ) -> None: + """Set preferred border agent id of a dataset.""" + self.datasets[dataset_id] = dataclasses.replace( + self.datasets[dataset_id], preferred_border_agent_id=border_agent_id + ) + self.async_schedule_save() + @property @callback def preferred_dataset(self) -> str | None: @@ -287,6 +323,7 @@ class DatasetStore: datasets[dataset["id"]] = DatasetEntry( created=created, id=dataset["id"], + preferred_border_agent_id=dataset["preferred_border_agent_id"], source=dataset["source"], tlv=dataset["tlv"], ) @@ -317,10 +354,16 @@ async def async_get_store(hass: HomeAssistant) -> DatasetStore: return store -async def async_add_dataset(hass: HomeAssistant, source: str, tlv: str) -> None: +async def async_add_dataset( + hass: HomeAssistant, + source: str, + tlv: str, + *, + preferred_border_agent_id: str | None = None, +) -> None: """Add a dataset.""" store = await async_get_store(hass) - store.async_add(source, tlv) + store.async_add(source, tlv, preferred_border_agent_id) 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 d07469f36fb..3395353b7bf 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -31,10 +31,11 @@ TYPE_PTR = 12 class ThreadRouterDiscoveryData: """Thread router discovery data.""" - addresses: list[str] | None + addresses: list[str] + border_agent_id: str | None brand: str | None - extended_address: str | None - extended_pan_id: str | None + extended_address: str + extended_pan_id: str model_name: str | None network_name: str | None server: str | None @@ -45,6 +46,8 @@ class ThreadRouterDiscoveryData: def async_discovery_data_from_service( service: AsyncServiceInfo, + ext_addr: bytes, + ext_pan_id: bytes, ) -> ThreadRouterDiscoveryData: """Get a ThreadRouterDiscoveryData from an AsyncServiceInfo.""" @@ -61,13 +64,14 @@ def async_discovery_data_from_service( # For legacy backwards compatibility zeroconf allows properties to be set # as strings but we never do that so we can safely cast here. service_properties = cast(dict[bytes, bytes | None], service.properties) - ext_addr = service_properties.get(b"xa") - ext_pan_id = service_properties.get(b"xp") - network_name = try_decode(service_properties.get(b"nn")) + + border_agent_id = service_properties.get(b"id") model_name = try_decode(service_properties.get(b"mn")) + network_name = try_decode(service_properties.get(b"nn")) server = service.server - vendor_name = try_decode(service_properties.get(b"vn")) thread_version = try_decode(service_properties.get(b"tv")) + vendor_name = try_decode(service_properties.get(b"vn")) + unconfigured = None brand = KNOWN_BRANDS.get(vendor_name) if brand == "homeassistant": @@ -84,9 +88,10 @@ def async_discovery_data_from_service( return ThreadRouterDiscoveryData( addresses=service.parsed_addresses(), + border_agent_id=border_agent_id.hex() if border_agent_id is not None else None, brand=brand, - extended_address=ext_addr.hex() if ext_addr is not None else None, - extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, + extended_address=ext_addr.hex(), + extended_pan_id=ext_pan_id.hex(), model_name=model_name, network_name=network_name, server=server, @@ -116,7 +121,19 @@ def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscover # data is not fully in the cache, so ignore for now continue - results.append(async_discovery_data_from_service(info)) + # Service properties are always bytes if they are set from the network. + # For legacy backwards compatibility zeroconf allows properties to be set + # as strings but we never do that so we can safely cast here. + service_properties = cast(dict[bytes, bytes | None], info.properties) + + if not (xa := service_properties.get(b"xa")): + _LOGGER.debug("Ignoring record without xa %s", info) + continue + if not (xp := service_properties.get(b"xp")): + _LOGGER.debug("Ignoring record without xp %s", info) + continue + + results.append(async_discovery_data_from_service(info, xa, xp)) return results @@ -177,18 +194,22 @@ class ThreadRouterDiscovery: # as strings but we never do that so we can safely cast here. service_properties = cast(dict[bytes, bytes | None], service.properties) + # We need xa and xp, bail out if either is missing if not (xa := service_properties.get(b"xa")): - _LOGGER.debug("_add_update_service failed to find xa in %s", service) + _LOGGER.info( + "Discovered unsupported Thread router without extended address: %s", + service, + ) + return + if not (xp := service_properties.get(b"xp")): + _LOGGER.info( + "Discovered unsupported Thread router without extended pan ID: %s", + service, + ) return - # We use the extended mac address as key, bail out if it's missing - try: - extended_mac_address = xa.hex() - except UnicodeDecodeError as err: - _LOGGER.debug("_add_update_service failed to parse service %s", err) - return - - data = async_discovery_data_from_service(service) + data = async_discovery_data_from_service(service, xa, xp) + extended_mac_address = xa.hex() if name in self._known_routers and self._known_routers[name] == ( extended_mac_address, data, diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 71dbb786eb5..eeac24a626f 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.3.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.5.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 60941426b7e..5b289cf1694 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,6 +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_dataset) @@ -50,6 +51,26 @@ async def ws_add_dataset( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "thread/set_preferred_border_agent_id", + vol.Required("dataset_id"): str, + vol.Required("border_agent_id"): str, + } +) +@websocket_api.async_response +async def ws_set_preferred_border_agent_id( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Set the preferred border agent ID.""" + dataset_id = msg["dataset_id"] + border_agent_id = msg["border_agent_id"] + store = await dataset_store.async_get_store(hass) + store.async_set_preferred_border_agent_id(dataset_id, border_agent_id) + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -152,6 +173,7 @@ async def ws_list_datasets( "network_name": dataset.network_name, "pan_id": dataset.pan_id, "preferred": dataset.id == preferred_dataset, + "preferred_border_agent_id": dataset.preferred_border_agent_id, "source": dataset.source, } ) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index a6621c096c3..3e702f0ebdb 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( EventStateChangedData, diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index c668430914f..1d8120a4321 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.28.0"] + "requirements": ["pyTibber==0.28.2"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 996490282d5..2694ef50e3a 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -37,8 +37,10 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.device_registry import async_get as async_get_dev_reg -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import ( + DeviceInfo, + async_get as async_get_dev_reg, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg from homeassistant.helpers.typing import StateType diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 8dba892de83..81d3fb00c6e 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -13,6 +13,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( @@ -82,6 +83,8 @@ class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerE """Representation of a network infrastructure device.""" _attr_icon = DEFAULT_ICON + _attr_has_entity_name = True + _attr_name = None def __init__( self, entry: ConfigEntry, coordinator: DataUpdateCoordinator[None], tile: Tile @@ -90,7 +93,6 @@ class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerE super().__init__(coordinator) self._attr_extra_state_attributes = {} - self._attr_name = tile.name self._attr_unique_id = f"{entry.data[CONF_USERNAME]}_{tile.uuid}" self._entry = entry self._tile = tile @@ -110,6 +112,11 @@ class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerE return super().location_accuracy return int(self._tile.accuracy) + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo(identifiers={(DOMAIN, self._tile.uuid)}, name=self._tile.name) + @property def latitude(self) -> float | None: """Return latitude value of the device.""" diff --git a/homeassistant/components/tod/manifest.json b/homeassistant/components/tod/manifest.json index a38531e8883..3d82c387ab7 100644 --- a/homeassistant/components/tod/manifest.json +++ b/homeassistant/components/tod/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tod", "integration_type": "helper", - "iot_class": "local_push", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index ac7e899d8a1..a83cdbe1b09 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], - "requirements": ["todoist-api-python==2.0.2"] + "requirements": ["todoist-api-python==2.1.2"] } diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index bb894753fb8..f0cf94bb825 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -13,7 +13,7 @@ from tololib.message_info import SettingsInfo, StatusInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index ce5ec4191c5..41fa8158624 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -26,8 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -222,7 +221,10 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self.async_refresh() self.update_interval = async_set_update_interval(self.hass, self._api) - self._schedule_refresh() + self._next_refresh = None + self._async_unsub_refresh() + if self._listeners: + self._schedule_refresh() async def async_unload_entry(self, entry: ConfigEntry) -> bool | None: """Unload a config entry from coordinator. diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index aba5b44f284..119a3dfe582 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -117,6 +118,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_DEW_POINT, name="Dew Point", + icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), @@ -141,6 +143,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", + icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, imperial_conversion=lambda val: DistanceConverter.convert( @@ -153,6 +156,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", + icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, imperial_conversion=lambda val: DistanceConverter.convert( @@ -164,12 +168,14 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_COVER, name="Cloud Cover", + icon="mdi:cloud-percent", native_unit_of_measurement=PERCENTAGE, ), # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( key=TMRW_ATTR_WIND_GUST, name="Wind Gust", + icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, unit_metric=UnitOfSpeed.METERS_PER_SECOND, imperial_conversion=lambda val: SpeedConverter.convert( @@ -269,9 +275,9 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_POLLEN_TREE, name="Tree Pollen Index", + icon="mdi:tree", value_map=PollenIndex, translation_key="pollen_index", - icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_POLLEN_WEED, @@ -283,9 +289,9 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_POLLEN_GRASS, name="Grass Pollen Index", + icon="mdi:grass", value_map=PollenIndex, translation_key="pollen_index", - icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( TMRW_ATTR_FIRE_INDEX, @@ -295,6 +301,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_UV_INDEX, name="UV Index", + state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( @@ -302,7 +309,7 @@ SENSOR_TYPES = ( name="UV Radiation Health Concern", value_map=UVDescription, translation_key="uv_index", - icon="mdi:sun-wireless", + icon="mdi:weather-sunny-alert", ), ) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 86b84ec3ca6..b0b82d81463 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -2,12 +2,13 @@ from __future__ import annotations from datetime import datetime -from typing import Any from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, @@ -15,7 +16,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - WeatherEntity, + DOMAIN as WEATHER_DOMAIN, + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -27,7 +31,8 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util @@ -41,6 +46,7 @@ from .const import ( DOMAIN, MAX_FORECASTS, TMRW_ATTR_CONDITION, + TMRW_ATTR_DEW_POINT, TMRW_ATTR_HUMIDITY, TMRW_ATTR_OZONE, TMRW_ATTR_PRECIPITATION, @@ -63,15 +69,31 @@ async def async_setup_entry( ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] + entity_registry = er.async_get(hass) + + entities = [TomorrowioWeatherEntity(config_entry, coordinator, 4, DAILY)] + + # Add hourly and nowcast entities to legacy config entries + for forecast_type in (HOURLY, NOWCAST): + if not entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(config_entry.unique_id, forecast_type), + ): + continue + entities.append( + TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) + ) - entities = [ - TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) - for forecast_type in (DAILY, HOURLY, NOWCAST) - ] async_add_entities(entities) -class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): +def _calculate_unique_id(config_entry_unique_id: str | None, forecast_type: str) -> str: + """Calculate unique ID.""" + return f"{config_entry_unique_id}_{forecast_type}" + + +class TomorrowioWeatherEntity(TomorrowioEntity, SingleCoordinatorWeatherEntity): """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -79,6 +101,9 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) def __init__( self, @@ -94,7 +119,9 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): forecast_type == DEFAULT_FORECAST_TYPE ) self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" - self._attr_unique_id = f"{config_entry.unique_id}_{forecast_type}" + self._attr_unique_id = _calculate_unique_id( + config_entry.unique_id, forecast_type + ) def _forecast_dict( self, @@ -102,12 +129,14 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): use_datetime: bool, condition: int, precipitation: float | None, - precipitation_probability: float | None, + precipitation_probability: int | None, temp: float | None, temp_low: float | None, + humidity: float | None, + dew_point: float | None, wind_direction: float | None, wind_speed: float | None, - ) -> dict[str, Any]: + ) -> Forecast: """Return formatted Forecast dict from Tomorrow.io forecast data.""" if use_datetime: translated_condition = self._translate_condition( @@ -116,19 +145,19 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): else: translated_condition = self._translate_condition(condition, True) - data = { + return { ATTR_FORECAST_TIME: forecast_dt.isoformat(), ATTR_FORECAST_CONDITION: translated_condition, ATTR_FORECAST_NATIVE_PRECIPITATION: precipitation, ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, ATTR_FORECAST_NATIVE_TEMP: temp, ATTR_FORECAST_NATIVE_TEMP_LOW: temp_low, + ATTR_FORECAST_HUMIDITY: humidity, + ATTR_FORECAST_NATIVE_DEW_POINT: dew_point, ATTR_FORECAST_WIND_BEARING: wind_direction, ATTR_FORECAST_NATIVE_WIND_SPEED: wind_speed, } - return {k: v for k, v in data.items() if v is not None} - @staticmethod def _translate_condition( condition: int | None, sun_is_up: bool = True @@ -187,20 +216,19 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): """Return the raw visibility.""" return self._get_current_property(TMRW_ATTR_VISIBILITY) - @property - def forecast(self): + def _forecast(self, forecast_type: str) -> list[Forecast] | None: """Return the forecast.""" # Check if forecasts are available raw_forecasts = ( self.coordinator.data.get(self._config_entry.entry_id, {}) .get(FORECASTS, {}) - .get(self.forecast_type) + .get(forecast_type) ) if not raw_forecasts: return None - forecasts = [] - max_forecasts = MAX_FORECASTS[self.forecast_type] + forecasts: list[Forecast] = [] + max_forecasts = MAX_FORECASTS[forecast_type] forecast_count = 0 # Convert utcnow to local to be compatible with tests @@ -212,7 +240,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): forecast_dt = dt_util.parse_datetime(forecast[TMRW_ATTR_TIMESTAMP]) # Throw out past data - if dt_util.as_local(forecast_dt).date() < today: + if forecast_dt is None or dt_util.as_local(forecast_dt).date() < today: continue values = forecast["values"] @@ -222,18 +250,25 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): precipitation = values.get(TMRW_ATTR_PRECIPITATION) precipitation_probability = values.get(TMRW_ATTR_PRECIPITATION_PROBABILITY) + try: + precipitation_probability = round(precipitation_probability) + except TypeError: + precipitation_probability = None + temp = values.get(TMRW_ATTR_TEMPERATURE_HIGH) temp_low = None + dew_point = values.get(TMRW_ATTR_DEW_POINT) + humidity = values.get(TMRW_ATTR_HUMIDITY) wind_direction = values.get(TMRW_ATTR_WIND_DIRECTION) wind_speed = values.get(TMRW_ATTR_WIND_SPEED) - if self.forecast_type == DAILY: + if forecast_type == DAILY: use_datetime = False temp_low = values.get(TMRW_ATTR_TEMPERATURE_LOW) if precipitation: precipitation = precipitation * 24 - elif self.forecast_type == NOWCAST: + elif forecast_type == NOWCAST: # Precipitation is forecasted in CONF_TIMESTEP increments but in a # per hour rate, so value needs to be converted to an amount. if precipitation: @@ -250,6 +285,8 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): precipitation_probability, temp, temp_low, + humidity, + dew_point, wind_direction, wind_speed, ) @@ -260,3 +297,18 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): break return forecasts + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast array.""" + return self._forecast(self.forecast_type) + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self._forecast(DAILY) + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return self._forecast(HOURLY) diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index e893bbf9e2c..75e3ddb0370 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 186f6805b7f..b89df6c9c25 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 2edea30835f..d8285cbed70 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -85,11 +85,23 @@ 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] try: - device: SmartDevice = await Discover.discover_single(entry.data[CONF_HOST]) + device: SmartDevice = await Discover.discover_single(host) except SmartDeviceException as ex: raise ConfigEntryNotReady from ex + 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 + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + ) + hass.data[DOMAIN][entry.entry_id] = TPLinkDataUpdateCoordinator(hass, device) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tplink/diagnostics.py b/homeassistant/components/tplink/diagnostics.py index 5121def2e47..c81356ee658 100644 --- a/homeassistant/components/tplink/diagnostics.py +++ b/homeassistant/components/tplink/diagnostics.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN from .coordinator import TPLinkDataUpdateCoordinator @@ -36,6 +37,8 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + oui = format_mac(coordinator.device.mac)[:8].upper() return async_redact_data( - {"device_last_response": coordinator.device.internal_state}, TO_REDACT + {"device_last_response": coordinator.device.internal_state, "oui": oui}, + TO_REDACT, ) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 4bf076a59bc..890793b898d 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -7,7 +7,7 @@ from typing import Any, Concatenate, ParamSpec, TypeVar from kasa import SmartDevice from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 3ff73501bdc..e9048a678ca 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,9 +1,9 @@ """Generic Omada API coordinator.""" +import asyncio from datetime import timedelta import logging from typing import Generic, TypeVar -import async_timeout from tplink_omada_client.exceptions import OmadaClientException from tplink_omada_client.omadaclient import OmadaSiteClient @@ -37,7 +37,7 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): async def _async_update_data(self) -> dict[str, T]: """Fetch data from API endpoint.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): return await self.poll_update() except OmadaClientException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index 41cb1c69180..bb330ef417a 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -4,7 +4,7 @@ from typing import Generic, TypeVar from tplink_omada_client.devices import OmadaDevice from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 685ad9c5761..1e653a53aae 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -8,7 +8,11 @@ from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice from tplink_omada_client.exceptions import OmadaClientException, RequestFailed from tplink_omada_client.omadasiteclient import OmadaSiteClient -from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -94,7 +98,7 @@ class OmadaDeviceUpdate( | UpdateEntityFeature.RELEASE_NOTES ) _attr_has_entity_name = True - _attr_name = "Firmware update" + _attr_device_class = UpdateDeviceClass.FIRMWARE def __init__( self, diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index ad31f20e3cf..f1236a66700 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, timedelta +from datetime import timedelta import logging from pytraccar import ( @@ -38,13 +38,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from . import DOMAIN, TRACKER_UPDATE from .const import ( @@ -334,7 +334,8 @@ class TraccarScanner: async def import_events(self): """Import events from Traccar.""" - start_intervel = datetime.utcnow() + # 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, diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 351b39f61e7..f2853e0032c 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( + ATTR_ACTIVITY_LABEL, ATTR_BUZZER, ATTR_CALORIES, ATTR_DAILY_GOAL, @@ -32,6 +33,7 @@ from .const import ( ATTR_MINUTES_DAY_SLEEP, ATTR_MINUTES_NIGHT_SLEEP, ATTR_MINUTES_REST, + ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, CLIENT, CLIENT_ID, @@ -89,7 +91,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from error tractive = TractiveClient(hass, client, creds["user_id"], entry) - tractive.subscribe() try: trackable_objects = await client.trackable_objects() @@ -97,7 +98,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: *(_generate_trackables(client, item) for item in trackable_objects) ) except aiotractive.exceptions.TractiveError as error: - await tractive.unsubscribe() raise ConfigEntryNotReady from error # When the pet defined in Tractive has no tracker linked we get None as `trackable`. @@ -173,6 +173,14 @@ class TractiveClient: """Return user id.""" return self._user_id + @property + def subscribed(self) -> bool: + """Return True if subscribed.""" + if self._listen_task is None: + return False + + return not self._listen_task.cancelled() + async def trackable_objects( self, ) -> list[aiotractive.trackable_object.TrackableObject]: @@ -201,6 +209,7 @@ class TractiveClient: while True: try: async for event in self._client.events(): + _LOGGER.debug("Received event: %s", event) if server_was_unavailable: _LOGGER.debug("Tractive is back online") server_was_unavailable = False @@ -231,7 +240,9 @@ class TractiveClient: self._config_entry.data[CONF_EMAIL], ) return - + except KeyError as error: + _LOGGER.error("Error while listening for events: %s", error) + continue except aiotractive.exceptions.TractiveError: _LOGGER.debug( ( @@ -274,10 +285,12 @@ class TractiveClient: def _send_wellness_update(self, event: dict[str, Any]) -> None: payload = { + ATTR_ACTIVITY_LABEL: event["wellness"].get("activity_label"), ATTR_CALORIES: event["activity"]["calories"], ATTR_MINUTES_DAY_SLEEP: event["sleep"]["minutes_day_sleep"], ATTR_MINUTES_NIGHT_SLEEP: event["sleep"]["minutes_night_sleep"], ATTR_MINUTES_REST: event["activity"]["minutes_rest"], + ATTR_SLEEP_LABEL: event["wellness"].get("sleep_label"), } self._dispatch_tracker_event( TRACKER_WELLNESS_STATUS_UPDATED, event["pet_id"], payload diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index d7968f15bf8..940ff82687e 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -11,17 +11,10 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables -from .const import ( - CLIENT, - DOMAIN, - SERVER_UNAVAILABLE, - TRACKABLES, - TRACKER_HARDWARE_STATUS_UPDATED, -) +from . import Trackables, TractiveClient +from .const import CLIENT, DOMAIN, TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED from .entity import TractiveEntity @@ -29,45 +22,29 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): """Tractive sensor.""" def __init__( - self, user_id: str, item: Trackables, description: BinarySensorEntityDescription + self, + client: TractiveClient, + item: Trackables, + description: BinarySensorEntityDescription, ) -> None: """Initialize sensor entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + super().__init__( + client, + item.trackable, + item.tracker_details, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" + self._attr_available = False self.entity_description = description @callback - def handle_server_unavailable(self) -> None: - """Handle server unavailable.""" - self._attr_available = False - self.async_write_ha_state() - - @callback - def handle_hardware_status_update(self, event: dict[str, Any]) -> None: - """Handle hardware status update.""" + def handle_status_update(self, event: dict[str, Any]) -> None: + """Handle status update.""" self._attr_is_on = event[self.entity_description.key] - self._attr_available = True - self.async_write_ha_state() - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", - self.handle_hardware_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) + super().handle_status_update(event) SENSOR_TYPE = BinarySensorEntityDescription( @@ -86,7 +63,7 @@ async def async_setup_entry( trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [ - TractiveBinarySensor(client.user_id, item, SENSOR_TYPE) + TractiveBinarySensor(client, item, SENSOR_TYPE) for item in trackables if item.tracker_details.get("charging_state") is not None ] diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index 81936ae5d80..254a8c274f3 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -6,6 +6,7 @@ DOMAIN = "tractive" RECONNECT_INTERVAL = timedelta(seconds=10) +ATTR_ACTIVITY_LABEL = "activity_label" ATTR_BUZZER = "buzzer" ATTR_CALORIES = "calories" ATTR_DAILY_GOAL = "daily_goal" @@ -15,6 +16,7 @@ ATTR_MINUTES_ACTIVE = "minutes_active" ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep" ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep" ATTR_MINUTES_REST = "minutes_rest" +ATTR_SLEEP_LABEL = "sleep_label" ATTR_TRACKER_STATE = "tracker_state" # This client ID was issued by Tractive specifically for Home Assistant. diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index a97ea963362..0e373e1a44f 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables +from . import Trackables, TractiveClient from .const import ( CLIENT, DOMAIN, @@ -28,7 +28,7 @@ async def async_setup_entry( client = hass.data[DOMAIN][entry.entry_id][CLIENT] trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] - entities = [TractiveDeviceTracker(client.user_id, item) for item in trackables] + entities = [TractiveDeviceTracker(client, item) for item in trackables] async_add_entities(entities) @@ -39,9 +39,14 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): _attr_icon = "mdi:paw" _attr_translation_key = "tracker" - def __init__(self, user_id: str, item: Trackables) -> None: + def __init__(self, client: TractiveClient, item: Trackables) -> None: """Initialize tracker entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + super().__init__( + client, + item.trackable, + item.tracker_details, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + ) self._battery_level: int | None = item.hw_info.get("battery_level") self._latitude: float = item.pos_report["latlong"][0] @@ -94,18 +99,15 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): self._attr_available = True self.async_write_ha_state() - @callback - def _handle_server_unavailable(self) -> None: - self._attr_available = False - self.async_write_ha_state() - async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" + if not self._client.subscribed: + self._client.subscribe() self.async_on_remove( async_dispatcher_connect( self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", + self._dispatcher_signal, self._handle_hardware_status_update, ) ) @@ -122,6 +124,6 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): async_dispatcher_connect( self.hass, f"{SERVER_UNAVAILABLE}-{self._user_id}", - self._handle_server_unavailable, + self.handle_server_unavailable, ) ) diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py index 712f8eda75a..da7beb8bcdd 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -3,9 +3,13 @@ from __future__ import annotations from typing import Any -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity -from .const import DOMAIN +from . import TractiveClient +from .const import DOMAIN, SERVER_UNAVAILABLE class TractiveEntity(Entity): @@ -14,7 +18,11 @@ class TractiveEntity(Entity): _attr_has_entity_name = True def __init__( - self, user_id: str, trackable: dict[str, Any], tracker_details: dict[str, Any] + self, + client: TractiveClient, + trackable: dict[str, Any], + tracker_details: dict[str, Any], + dispatcher_signal: str, ) -> None: """Initialize tracker entity.""" self._attr_device_info = DeviceInfo( @@ -25,6 +33,40 @@ class TractiveEntity(Entity): sw_version=tracker_details["fw_version"], model=tracker_details["model_number"], ) - self._user_id = user_id + self._user_id = client.user_id self._tracker_id = tracker_details["_id"] - self._trackable = trackable + self._client = client + self._dispatcher_signal = dispatcher_signal + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + if not self._client.subscribed: + self._client.subscribe() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._dispatcher_signal, + self.handle_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + @callback + def handle_status_update(self, event: dict[str, Any]) -> None: + """Handle status update.""" + self._attr_available = event[self.entity_description.key] is not None + self.async_write_ha_state() + + @callback + def handle_server_unavailable(self) -> None: + """Handle server unavailable.""" + self._attr_available = False + self.async_write_ha_state() diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 9e448d1fd26..75ddf065bd7 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==0.5.5"] + "requirements": ["aiotractive==0.5.6"] } diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 493b627f9b4..49eda4f8d09 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -1,6 +1,7 @@ """Support for Tractive sensors.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -18,21 +19,22 @@ from homeassistant.const import ( UnitOfTime, ) 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 Trackables +from . import Trackables, TractiveClient from .const import ( + ATTR_ACTIVITY_LABEL, ATTR_CALORIES, ATTR_DAILY_GOAL, ATTR_MINUTES_ACTIVE, ATTR_MINUTES_DAY_SLEEP, ATTR_MINUTES_NIGHT_SLEEP, ATTR_MINUTES_REST, + ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, CLIENT, DOMAIN, - SERVER_UNAVAILABLE, TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, @@ -45,7 +47,7 @@ from .entity import TractiveEntity class TractiveRequiredKeysMixin: """Mixin for required keys.""" - entity_class: type[TractiveSensor] + signal_prefix: str @dataclass @@ -54,112 +56,44 @@ class TractiveSensorEntityDescription( ): """Class describing Tractive sensor entities.""" + hardware_sensor: bool = False + value_fn: Callable[[StateType], StateType] = lambda state: state + class TractiveSensor(TractiveEntity, SensorEntity): """Tractive sensor.""" + entity_description: TractiveSensorEntityDescription + def __init__( self, - user_id: str, + client: TractiveClient, item: Trackables, description: TractiveSensorEntityDescription, ) -> None: """Initialize sensor entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + if description.hardware_sensor: + dispatcher_signal = ( + f"{description.signal_prefix}-{item.tracker_details['_id']}" + ) + else: + dispatcher_signal = f"{description.signal_prefix}-{item.trackable['_id']}" + super().__init__( + client, item.trackable, item.tracker_details, dispatcher_signal + ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" - self.entity_description = description - - @callback - def handle_server_unavailable(self) -> None: - """Handle server unavailable.""" self._attr_available = False - self.async_write_ha_state() - - -class TractiveHardwareSensor(TractiveSensor): - """Tractive hardware sensor.""" - - @callback - def handle_hardware_status_update(self, event: dict[str, Any]) -> None: - """Handle hardware status update.""" - if (_state := event[self.entity_description.key]) is None: - return - self._attr_native_value = _state - self._attr_available = True - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", - self.handle_hardware_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) - - -class TractiveActivitySensor(TractiveSensor): - """Tractive active sensor.""" + self.entity_description = description @callback def handle_status_update(self, event: dict[str, Any]) -> None: """Handle status update.""" - self._attr_native_value = event[self.entity_description.key] - self._attr_available = True - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_ACTIVITY_STATUS_UPDATED}-{self._trackable['_id']}", - self.handle_status_update, - ) + self._attr_native_value = self.entity_description.value_fn( + event[self.entity_description.key] ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) - - -class TractiveWellnessSensor(TractiveActivitySensor): - """Tractive wellness sensor.""" - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_WELLNESS_STATUS_UPDATED}-{self._trackable['_id']}", - self.handle_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) + super().handle_status_update(event) SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( @@ -168,13 +102,15 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="tracker_battery_level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, - entity_class=TractiveHardwareSensor, + signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED, + hardware_sensor=True, entity_category=EntityCategory.DIAGNOSTIC, ), TractiveSensorEntityDescription( key=ATTR_TRACKER_STATE, translation_key="tracker_state", - entity_class=TractiveHardwareSensor, + signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED, + hardware_sensor=True, icon="mdi:radar", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, @@ -190,7 +126,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="activity_time", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveActivitySensor, + signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -198,7 +134,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="rest_time", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -206,7 +142,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="calories", icon="mdi:fire", native_unit_of_measurement="kcal", - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -214,14 +150,14 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="daily_goal", icon="mdi:flag-checkered", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveActivitySensor, + signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_DAY_SLEEP, translation_key="minutes_day_sleep", icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( @@ -229,9 +165,35 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="minutes_night_sleep", icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, - entity_class=TractiveWellnessSensor, + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, ), + TractiveSensorEntityDescription( + key=ATTR_SLEEP_LABEL, + translation_key="sleep", + icon="mdi:sleep", + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, + value_fn=lambda state: state.lower() if isinstance(state, str) else state, + device_class=SensorDeviceClass.ENUM, + options=[ + "good", + "low", + "ok", + ], + ), + TractiveSensorEntityDescription( + key=ATTR_ACTIVITY_LABEL, + translation_key="activity", + icon="mdi:run", + signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, + value_fn=lambda state: state.lower() if isinstance(state, str) else state, + device_class=SensorDeviceClass.ENUM, + options=[ + "good", + "low", + "ok", + ], + ), ) @@ -243,7 +205,7 @@ async def async_setup_entry( trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [ - description.entity_class(client.user_id, item, description) + TractiveSensor(client, item, description) for description in SENSOR_TYPES for item in trackables ] diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index 4053d2658f5..82b7ecc295c 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -30,15 +30,23 @@ } }, "sensor": { + "activity": { + "name": "Activity", + "state": { + "good": "Good", + "low": "Low", + "ok": "OK" + } + }, + "activity_time": { + "name": "Activity time" + }, "calories": { "name": "Calories burned" }, "daily_goal": { "name": "Daily goal" }, - "activity_time": { - "name": "Activity time" - }, "minutes_day_sleep": { "name": "Day sleep" }, @@ -48,6 +56,14 @@ "rest_time": { "name": "Rest time" }, + "sleep": { + "name": "Sleep", + "state": { + "good": "[%key:component::tractive::entity::sensor::activity::state::good%]", + "low": "[%key:component::tractive::entity::sensor::activity::state::low%]", + "ok": "[%key:component::tractive::entity::sensor::activity::state::ok%]" + } + }, "tracker_battery_level": { "name": "Tracker battery" }, diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 6d8274df253..55acdb9bdcd 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -11,17 +11,15 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import Trackables +from . import Trackables, TractiveClient from .const import ( ATTR_BUZZER, ATTR_LED, ATTR_LIVE_TRACKING, CLIENT, DOMAIN, - SERVER_UNAVAILABLE, TRACKABLES, TRACKER_HARDWARE_STATUS_UPDATED, ) @@ -77,7 +75,7 @@ async def async_setup_entry( trackables = hass.data[DOMAIN][entry.entry_id][TRACKABLES] entities = [ - TractiveSwitch(client.user_id, item, description) + TractiveSwitch(client, item, description) for description in SWITCH_TYPES for item in trackables ] @@ -92,12 +90,17 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): def __init__( self, - user_id: str, + client: TractiveClient, item: Trackables, description: TractiveSwitchEntityDescription, ) -> None: """Initialize switch entity.""" - super().__init__(user_id, item.trackable, item.tracker_details) + super().__init__( + client, + item.trackable, + item.tracker_details, + f"{TRACKER_HARDWARE_STATUS_UPDATED}-{item.tracker_details['_id']}", + ) self._attr_unique_id = f"{item.trackable['_id']}_{description.key}" self._attr_available = False @@ -106,38 +109,11 @@ class TractiveSwitch(TractiveEntity, SwitchEntity): self.entity_description = description @callback - def handle_server_unavailable(self) -> None: - """Handle server unavailable.""" - self._attr_available = False - self.async_write_ha_state() + def handle_status_update(self, event: dict[str, Any]) -> None: + """Handle status update.""" + self._attr_is_on = event[self.entity_description.key] - @callback - def handle_hardware_status_update(self, event: dict[str, Any]) -> None: - """Handle hardware status update.""" - if (state := event[self.entity_description.key]) is None: - return - self._attr_is_on = state - self._attr_available = True - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{TRACKER_HARDWARE_STATUS_UPDATED}-{self._tracker_id}", - self.handle_hardware_status_update, - ) - ) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SERVER_UNAVAILABLE}-{self._user_id}", - self.handle_server_unavailable, - ) - ) + super().handle_status_update(event) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on a switch.""" diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index c7154c19f15..d186e19a2c8 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -11,7 +11,7 @@ from pytradfri.device import Device from pytradfri.error import RequestError from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 1e9b63bb325..2a3052c1f7b 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -5,7 +5,6 @@ import asyncio from typing import Any from uuid import uuid4 -import async_timeout from pytradfri import Gateway, RequestError from pytradfri.api.aiocoap_api import APIFactory import voluptuous as vol @@ -141,7 +140,7 @@ async def authenticate( api_factory = await APIFactory.init(host, psk_id=identity) try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): key = await api_factory.generate_psk(security_code) except RequestError as err: raise AuthError("invalid_security_code") from err diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py new file mode 100644 index 00000000000..dfac8416c49 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -0,0 +1,44 @@ +"""The trafikverket_camera component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.integration_platform import ( + async_process_integration_platform_for_component, +) +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, PLATFORMS +from .coordinator import TVDataUpdateCoordinator + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up trafikverket_camera.""" + # Process integration platforms right away since + # we will create entities before firing EVENT_COMPONENT_LOADED + await async_process_integration_platform_for_component(hass, DOMAIN) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Trafikverket Camera from a config entry.""" + + coordinator = TVDataUpdateCoordinator(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 Trafikverket Camera 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/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py new file mode 100644 index 00000000000..936e460638f --- /dev/null +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -0,0 +1,84 @@ +"""Camera for the Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LOCATION +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 ATTR_DESCRIPTION, ATTR_TYPE, DOMAIN +from .coordinator import TVDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Trafikverket Camera.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + TVCamera( + coordinator, + entry.title, + entry.entry_id, + ) + ], + ) + + +class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): + """Implement Trafikverket camera.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_translation_key = "tv_camera" + coordinator: TVDataUpdateCoordinator + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + name: str, + entry_id: str, + ) -> None: + """Initialize the camera.""" + super().__init__(coordinator) + Camera.__init__(self) + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Trafikverket", + model="v1.0", + name=name, + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return camera picture.""" + return self.coordinator.data.image + + @property + def is_on(self) -> bool: + """Return camera on.""" + return self.coordinator.data.data.active is True + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + return { + ATTR_DESCRIPTION: self.coordinator.data.data.description, + ATTR_LOCATION: self.coordinator.data.data.location, + ATTR_TYPE: self.coordinator.data.data.camera_type, + } diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py new file mode 100644 index 00000000000..b8a14a5424e --- /dev/null +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -0,0 +1,122 @@ +"""Adds config flow for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) +from pytrafikverket.trafikverket_camera import TrafikverketCamera +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_LOCATION, DOMAIN + + +class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Trafikverket Camera integration.""" + + VERSION = 1 + + entry: config_entries.ConfigEntry | None + + async def validate_input(self, sensor_api: str, location: str) -> dict[str, str]: + """Validate input from user input.""" + errors: dict[str, str] = {} + + web_session = async_get_clientsession(self.hass) + camera_api = TrafikverketCamera(web_session, sensor_api) + try: + await camera_api.async_get_camera(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" + + return errors + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle re-authentication with Trafikverket.""" + + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm re-authentication with Trafikverket.""" + errors = {} + + if user_input: + api_key = user_input[CONF_API_KEY] + + assert self.entry is not None + errors = await self.validate_input(api_key, self.entry.data[CONF_LOCATION]) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_API_KEY: api_key, + }, + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + } + ), + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input: + api_key = user_input[CONF_API_KEY] + location = user_input[CONF_LOCATION] + + errors = await self.validate_input(api_key, location) + + if not errors: + await self.async_set_unique_id(f"{DOMAIN}-{location}") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_LOCATION], + data={ + CONF_API_KEY: api_key, + CONF_LOCATION: location, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_LOCATION): cv.string, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py new file mode 100644 index 00000000000..6657ab1a853 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/const.py @@ -0,0 +1,10 @@ +"""Adds constants for Trafikverket Camera integration.""" +from homeassistant.const import Platform + +DOMAIN = "trafikverket_camera" +CONF_LOCATION = "location" +PLATFORMS = [Platform.CAMERA] +ATTRIBUTION = "Data provided by Trafikverket" + +ATTR_DESCRIPTION = "description" +ATTR_TYPE = "type" diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py new file mode 100644 index 00000000000..eb5a047ca73 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -0,0 +1,76 @@ +"""DataUpdateCoordinator for the Trafikverket Camera integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from io import BytesIO +import logging + +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) +from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_LOCATION, DOMAIN + +_LOGGER = logging.getLogger(__name__) +TIME_BETWEEN_UPDATES = timedelta(minutes=5) + + +@dataclass +class CameraData: + """Dataclass for Camera data.""" + + data: CameraInfo + image: bytes | None + + +class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): + """A Trafikverket Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Trafikverket coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=TIME_BETWEEN_UPDATES, + ) + self.session = async_get_clientsession(hass) + self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY]) + self._location = entry.data[CONF_LOCATION] + + async def _async_update_data(self) -> CameraData: + """Fetch data from Trafikverket.""" + camera_data: CameraInfo + image: bytes | None = None + try: + camera_data = await self._camera_api.async_get_camera(self._location) + except (NoCameraFound, MultipleCamerasFound, UnknownError) as error: + raise UpdateFailed from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + + if camera_data.photourl is None: + return CameraData(data=camera_data, image=None) + + image_url = camera_data.photourl + if camera_data.fullsizephoto: + image_url = f"{camera_data.photourl}?type=fullsize" + + async with self.session.get(image_url, timeout=10) as get_image: + if get_image.status not in range(200, 299): + raise UpdateFailed("Could not retrieve image") + image = BytesIO(await get_image.read()).getvalue() + + return CameraData(data=camera_data, image=image) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json new file mode 100644 index 00000000000..440d7237171 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "trafikverket_camera", + "name": "Trafikverket Camera", + "codeowners": ["@gjohansson-ST"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", + "iot_class": "cloud_polling", + "loggers": ["pytrafikverket"], + "requirements": ["pytrafikverket==0.3.5"] +} diff --git a/homeassistant/components/trafikverket_camera/recorder.py b/homeassistant/components/trafikverket_camera/recorder.py new file mode 100644 index 00000000000..b6b608749ad --- /dev/null +++ b/homeassistant/components/trafikverket_camera/recorder.py @@ -0,0 +1,13 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.const import ATTR_LOCATION +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_DESCRIPTION + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude description and location from being recorded in the database.""" + return {ATTR_DESCRIPTION, ATTR_LOCATION} diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json new file mode 100644 index 00000000000..c128f7729bc --- /dev/null +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_location": "Could not find a camera location with the specified name", + "more_locations": "Found multiple camera locations with the specified name" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "location": "[%key:common::config_flow::data::location%]" + } + } + } + }, + "entity": { + "camera": { + "tv_camera": { + "state_attributes": { + "description": { + "name": "Description" + }, + "direction": { + "name": "Direction" + }, + "full_size_photo": { + "name": "Full size photo" + }, + "location": { + "name": "[%key:common::config_flow::data::location%]" + }, + "photo_url": { + "name": "Photo url" + }, + "status": { + "name": "Status" + }, + "type": { + "name": "Camera type" + } + } + } + } + } +} diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 5822566505b..47f1e62be00 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.3"] + "requirements": ["pytrafikverket==0.3.5"] } diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index 366c193f8fe..a673f624a47 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -14,8 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index dd35d058ed5..a7defa2956a 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -12,9 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_FROM, CONF_TO, DOMAIN, PLATFORMS +from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TO, DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator @@ -35,11 +36,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f" {entry.data[CONF_TO]}. Error: {error} " ) from error - coordinator = TVDataUpdateCoordinator(hass, entry, to_station, from_station) + coordinator = TVDataUpdateCoordinator( + hass, entry, to_station, from_station, entry.options.get(CONF_FILTER_PRODUCT) + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entity_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entries: + if not entity.unique_id.startswith(entry.entry_id): + entity_reg.async_update_entity( + entity.entity_id, new_unique_id=f"{entry.entry_id}-departure_time" + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -48,3 +60,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Trafikverket Weatherstation config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index fc23d3b953d..b7808dc38b2 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -2,18 +2,24 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime +import logging from typing import Any from pytrafikverket import TrafikverketTrain from pytrafikverket.exceptions import ( InvalidAuthentication, + MultipleTrainAnnouncementFound, MultipleTrainStationsFound, + NoTrainAnnouncementFound, NoTrainStationFound, + UnknownError, ) import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -22,18 +28,25 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, TextSelector, + TimeSelector, ) import homeassistant.util.dt as dt_util -from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN -from .util import create_unique_id +from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TIME, CONF_TO, DOMAIN +from .util import create_unique_id, next_departuredate + +_LOGGER = logging.getLogger(__name__) + +OPTION_SCHEMA = { + vol.Optional(CONF_FILTER_PRODUCT, default=""): TextSelector(), +} DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): TextSelector(), vol.Required(CONF_FROM): TextSelector(), vol.Required(CONF_TO): TextSelector(), - vol.Optional(CONF_TIME): TextSelector(), + vol.Optional(CONF_TIME): TimeSelector(), vol.Required(CONF_WEEKDAY, default=WEEKDAYS): SelectSelector( SelectSelectorConfig( options=WEEKDAYS, @@ -43,7 +56,7 @@ DATA_SCHEMA = vol.Schema( ) ), } -) +).extend(OPTION_SCHEMA) DATA_SCHEMA_REAUTH = vol.Schema( { vol.Required(CONF_API_KEY): cv.string, @@ -51,6 +64,61 @@ DATA_SCHEMA_REAUTH = vol.Schema( ) +async def validate_input( + hass: HomeAssistant, + api_key: str, + train_from: str, + train_to: str, + train_time: str | None, + weekdays: list[str], + product_filter: str | None, +) -> dict[str, str]: + """Validate input from user input.""" + errors: dict[str, str] = {} + + when = dt_util.now() + if train_time: + departure_day = next_departuredate(weekdays) + if _time := dt_util.parse_time(train_time): + when = datetime.combine( + departure_day, + _time, + dt_util.get_time_zone(hass.config.time_zone), + ) + + try: + web_session = async_get_clientsession(hass) + train_api = TrafikverketTrain(web_session, api_key) + from_station = await train_api.async_get_train_station(train_from) + to_station = await train_api.async_get_train_station(train_to) + if train_time: + await train_api.async_get_train_stop( + from_station, to_station, when, product_filter + ) + else: + await train_api.async_get_next_train_stop( + from_station, to_station, when, product_filter + ) + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoTrainStationFound: + errors["base"] = "invalid_station" + except MultipleTrainStationsFound: + errors["base"] = "more_stations" + except NoTrainAnnouncementFound: + errors["base"] = "no_trains" + except MultipleTrainAnnouncementFound: + errors["base"] = "multiple_trains" + except UnknownError as error: + _LOGGER.error("Unknown error occurred during validation %s", str(error)) + errors["base"] = "cannot_connect" + except Exception as error: # pylint: disable=broad-exception-caught + _LOGGER.error("Unknown exception occurred during validation %s", str(error)) + errors["base"] = "cannot_connect" + + return errors + + class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Train integration.""" @@ -58,14 +126,13 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None - async def validate_input( - self, api_key: str, train_from: str, train_to: str - ) -> None: - """Validate input from user input.""" - web_session = async_get_clientsession(self.hass) - train_api = TrafikverketTrain(web_session, api_key) - await train_api.async_get_train_station(train_from) - await train_api.async_get_train_station(train_to) + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> TVTrainOptionsFlowHandler: + """Get the options flow for this handler.""" + return TVTrainOptionsFlowHandler(config_entry) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -83,19 +150,16 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - try: - await self.validate_input( - api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO] - ) - except InvalidAuthentication: - errors["base"] = "invalid_auth" - except NoTrainStationFound: - errors["base"] = "invalid_station" - except MultipleTrainStationsFound: - errors["base"] = "more_stations" - except Exception: # pylint: disable=broad-exception-caught - errors["base"] = "cannot_connect" - else: + errors = await validate_input( + self.hass, + api_key, + self.entry.data[CONF_FROM], + self.entry.data[CONF_TO], + self.entry.data.get(CONF_TIME), + self.entry.data[CONF_WEEKDAY], + self.entry.options.get(CONF_FILTER_PRODUCT), + ) + if not errors: self.hass.config_entries.async_update_entry( self.entry, data={ @@ -124,45 +188,71 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): train_to: str = user_input[CONF_TO] train_time: str | None = user_input.get(CONF_TIME) train_days: list = user_input[CONF_WEEKDAY] + filter_product: str | None = user_input[CONF_FILTER_PRODUCT] + + if filter_product == "": + filter_product = None name = f"{train_from} to {train_to}" if train_time: name = f"{train_from} to {train_to} at {train_time}" - try: - await self.validate_input(api_key, train_from, train_to) - except InvalidAuthentication: - errors["base"] = "invalid_auth" - except NoTrainStationFound: - errors["base"] = "invalid_station" - except MultipleTrainStationsFound: - errors["base"] = "more_stations" - except Exception: # pylint: disable=broad-exception-caught - errors["base"] = "cannot_connect" - else: - if train_time: - if bool(dt_util.parse_time(train_time) is None): - errors["base"] = "invalid_time" - if not errors: - unique_id = create_unique_id( - train_from, train_to, train_time, train_days - ) - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={ - CONF_API_KEY: api_key, - CONF_NAME: name, - CONF_FROM: train_from, - CONF_TO: train_to, - CONF_TIME: train_time, - CONF_WEEKDAY: train_days, - }, - ) + errors = await validate_input( + self.hass, + api_key, + train_from, + train_to, + train_time, + train_days, + filter_product, + ) + if not errors: + unique_id = create_unique_id( + train_from, train_to, train_time, train_days + ) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name, + data={ + CONF_API_KEY: api_key, + CONF_NAME: name, + CONF_FROM: train_from, + CONF_TO: train_to, + CONF_TIME: train_time, + CONF_WEEKDAY: train_days, + }, + options={CONF_FILTER_PRODUCT: filter_product}, + ) return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=self.add_suggested_values_to_schema( + DATA_SCHEMA, user_input or {} + ), + errors=errors, + ) + + +class TVTrainOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): + """Handle Trafikverket Train options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Trafikverket Train options.""" + errors: dict[str, Any] = {} + + if user_input: + if not (_filter := user_input.get(CONF_FILTER_PRODUCT)) or _filter == "": + user_input[CONF_FILTER_PRODUCT] = None + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(OPTION_SCHEMA), + user_input or self.options, + ), errors=errors, ) diff --git a/homeassistant/components/trafikverket_train/const.py b/homeassistant/components/trafikverket_train/const.py index 253383b4b5a..e1852ce9ada 100644 --- a/homeassistant/components/trafikverket_train/const.py +++ b/homeassistant/components/trafikverket_train/const.py @@ -8,3 +8,4 @@ ATTRIBUTION = "Data provided by Trafikverket" CONF_FROM = "from" CONF_TO = "to" CONF_TIME = "time" +CONF_FILTER_PRODUCT = "filter_product" diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index fba6eb93dd9..ea852ab7fdf 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -2,14 +2,20 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import date, datetime, time, timedelta +from datetime import datetime, time, timedelta import logging from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainAnnouncementFound, + NoTrainAnnouncementFound, + UnknownError, +) from pytrafikverket.trafikverket_train import StationInfo, TrainStop from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY, WEEKDAYS +from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -17,6 +23,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from homeassistant.util import dt as dt_util from .const import CONF_TIME, DOMAIN +from .util import next_departuredate @dataclass @@ -32,33 +39,13 @@ class TrainData: actual_time: datetime | None other_info: str | None deviation: str | None + product_filter: str | None _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=5) -def _next_weekday(fromdate: date, weekday: int) -> date: - """Return the date of the next time a specific weekday happen.""" - days_ahead = weekday - fromdate.weekday() - if days_ahead <= 0: - days_ahead += 7 - return fromdate + timedelta(days_ahead) - - -def _next_departuredate(departure: list[str]) -> date: - """Calculate the next departuredate from an array input of short days.""" - today_date = date.today() - today_weekday = date.weekday(today_date) - if WEEKDAYS[today_weekday] in departure: - return today_date - for day in departure: - next_departure = WEEKDAYS.index(day) - if next_departure > today_weekday: - return _next_weekday(today_date, next_departure) - return _next_weekday(today_date, WEEKDAYS.index(departure[0])) - - def _get_as_utc(date_value: datetime | None) -> datetime | None: """Return utc datetime or None.""" if date_value: @@ -82,6 +69,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): entry: ConfigEntry, to_station: StationInfo, from_station: StationInfo, + filter_product: str | None, ) -> None: """Initialize the Trafikverket coordinator.""" super().__init__( @@ -97,6 +85,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): self.to_station: StationInfo = to_station self._time: time | None = dt_util.parse_time(entry.data[CONF_TIME]) self._weekdays: list[str] = entry.data[CONF_WEEKDAY] + self._filter_product = filter_product async def _async_update_data(self) -> TrainData: """Fetch data from Trafikverket.""" @@ -104,7 +93,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): when = dt_util.now() state: TrainStop | None = None if self._time: - departure_day = _next_departuredate(self._weekdays) + departure_day = next_departuredate(self._weekdays) when = datetime.combine( departure_day, self._time, @@ -113,15 +102,19 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): try: if self._time: state = await self._train_api.async_get_train_stop( - self.from_station, self.to_station, when + self.from_station, self.to_station, when, self._filter_product ) else: state = await self._train_api.async_get_next_train_stop( - self.from_station, self.to_station, when + self.from_station, self.to_station, when, self._filter_product ) - except ValueError as error: - if "Invalid authentication" in error.args[0]: - raise ConfigEntryAuthFailed from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except ( + NoTrainAnnouncementFound, + MultipleTrainAnnouncementFound, + UnknownError, + ) as error: raise UpdateFailed( f"Train departure {when} encountered a problem: {error}" ) from error @@ -144,6 +137,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): actual_time=_get_as_utc(state.time_at_location), other_info=_get_as_joined(state.other_information), deviation=_get_as_joined(state.deviations), + product_filter=self._filter_product, ) return states diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 7b8369cec17..47b4c21c867 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.3"] + "requirements": ["pytrafikverket==0.3.5"] } diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index f57850e51b8..a5e76299b61 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -1,36 +1,111 @@ """Train information for departures and delays, provided by Trafikverket.""" from __future__ import annotations -from datetime import time, timedelta -from typing import TYPE_CHECKING +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any -from pytrafikverket.trafikverket_train import StationInfo - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_WEEKDAY +from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util -from .const import CONF_TIME, DOMAIN -from .coordinator import TVDataUpdateCoordinator -from .util import create_unique_id +from .const import ATTRIBUTION, DOMAIN +from .coordinator import TrainData, TVDataUpdateCoordinator -ATTR_DEPARTURE_STATE = "departure_state" -ATTR_CANCELED = "canceled" -ATTR_DELAY_TIME = "number_of_minutes_delayed" -ATTR_PLANNED_TIME = "planned_time" -ATTR_ESTIMATED_TIME = "estimated_time" -ATTR_ACTUAL_TIME = "actual_time" -ATTR_OTHER_INFORMATION = "other_information" -ATTR_DEVIATIONS = "deviations" +ATTR_PRODUCT_FILTER = "product_filter" -ICON = "mdi:train" -SCAN_INTERVAL = timedelta(minutes=5) + +@dataclass +class TrafikverketRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[TrainData], StateType | datetime] + + +@dataclass +class TrafikverketSensorEntityDescription( + SensorEntityDescription, TrafikverketRequiredKeysMixin +): + """Describes Trafikverket sensor entity.""" + + +SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( + TrafikverketSensorEntityDescription( + key="departure_time", + translation_key="departure_time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.departure_time, + ), + TrafikverketSensorEntityDescription( + key="departure_state", + translation_key="departure_state", + icon="mdi:clock", + value_fn=lambda data: data.departure_state, + device_class=SensorDeviceClass.ENUM, + options=["on_time", "delayed", "canceled"], + ), + TrafikverketSensorEntityDescription( + key="cancelled", + translation_key="cancelled", + icon="mdi:alert", + value_fn=lambda data: data.cancelled, + ), + TrafikverketSensorEntityDescription( + key="delayed_time", + translation_key="delayed_time", + icon="mdi:clock", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + value_fn=lambda data: data.delayed_time, + ), + TrafikverketSensorEntityDescription( + key="planned_time", + translation_key="planned_time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.planned_time, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="estimated_time", + translation_key="estimated_time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.estimated_time, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="actual_time", + translation_key="actual_time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.actual_time, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="other_info", + translation_key="other_info", + icon="mdi:information-variant", + value_fn=lambda data: data.other_info, + ), + TrafikverketSensorEntityDescription( + key="deviation", + translation_key="deviation", + icon="mdi:alert", + value_fn=lambda data: data.deviation, + ), +) async def async_setup_entry( @@ -40,82 +115,55 @@ async def async_setup_entry( coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - to_station = coordinator.to_station - from_station = coordinator.from_station - get_time: str | None = entry.data.get(CONF_TIME) - train_time = dt_util.parse_time(get_time) if get_time else None - async_add_entities( [ - TrainSensor( - coordinator, - entry.data[CONF_NAME], - from_station, - to_station, - entry.data[CONF_WEEKDAY], - train_time, - entry.entry_id, - ) - ], - True, + TrainSensor(coordinator, entry.data[CONF_NAME], entry.entry_id, description) + for description in SENSOR_TYPES + ] ) class TrainSensor(CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity): """Contains data about a train depature.""" - _attr_icon = ICON - _attr_device_class = SensorDeviceClass.TIMESTAMP + entity_description: TrafikverketSensorEntityDescription + _attr_attribution = ATTRIBUTION _attr_has_entity_name = True - _attr_name = None def __init__( self, coordinator: TVDataUpdateCoordinator, name: str, - from_station: StationInfo, - to_station: StationInfo, - weekday: list, - departuretime: time | None, entry_id: str, + entity_description: TrafikverketSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self.entity_description = entity_description self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v2.0", name=name, configuration_url="https://api.trafikinfo.trafikverket.se/", ) - if TYPE_CHECKING: - assert from_station.name and to_station.name - self._attr_unique_id = create_unique_id( - from_station.name, to_station.name, departuretime, weekday - ) self._update_attr() + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + @callback def _handle_coordinator_update(self) -> None: self._update_attr() return super()._handle_coordinator_update() - @callback - def _update_attr(self) -> None: - """Retrieve latest state.""" - - data = self.coordinator.data - - self._attr_native_value = data.departure_time - - self._attr_extra_state_attributes = { - ATTR_DEPARTURE_STATE: data.departure_state, - ATTR_CANCELED: data.cancelled, - ATTR_DELAY_TIME: data.delayed_time, - ATTR_PLANNED_TIME: data.planned_time, - ATTR_ESTIMATED_TIME: data.estimated_time, - ATTR_ACTUAL_TIME: data.actual_time, - ATTR_OTHER_INFORMATION: data.other_info, - ATTR_DEVIATIONS: data.deviation, - } + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes for Trafikverket Train sensor.""" + if self.coordinator.data.product_filter: + return {ATTR_PRODUCT_FILTER: self.coordinator.data.product_filter} + return None diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 0089f6db8fc..78d69c880ae 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -9,7 +9,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_station": "Could not find a station with the specified name", "more_stations": "Found multiple stations with the specified name", - "invalid_time": "Invalid time provided", + "no_trains": "No train found", + "multiple_trains": "Multiple trains found", "incorrect_api_key": "Invalid API key for selected account" }, "step": { @@ -19,7 +20,12 @@ "to": "To station", "from": "From station", "time": "Time (optional)", - "weekday": "Days" + "weekday": "Days", + "filter_product": "Filter by product description" + }, + "data_description": { + "time": "Set time to search specifically at this time of day, must be exact time as scheduled train departure", + "filter_product": "To filter by product description add the phrase here to match" } }, "reauth_confirm": { @@ -29,6 +35,18 @@ } } }, + "options": { + "step": { + "init": { + "data": { + "filter_product": "[%key:component::trafikverket_train::config::step::user::data::filter_product%]" + }, + "data_description": { + "filter_product": "[%key:component::trafikverket_train::config::step::user::data_description::filter_product%]" + } + } + } + }, "selector": { "weekday": { "options": { @@ -41,5 +59,86 @@ "sun": "[%key:common::time::sunday%]" } } + }, + "entity": { + "sensor": { + "departure_time": { + "name": "Departure time", + "state_attributes": { + "product_filter": { + "name": "Train filtering" + } + } + }, + "departure_state": { + "name": "Departure state", + "state": { + "on_time": "On time", + "delayed": "Delayed", + "canceled": "Cancelled" + }, + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "cancelled": { + "name": "Cancelled", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "delayed_time": { + "name": "Delayed time", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "planned_time": { + "name": "Planned time", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "estimated_time": { + "name": "Estimated time", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "actual_time": { + "name": "Actual time", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "other_info": { + "name": "Other information", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "deviation": { + "name": "Deviation", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + } + } } } diff --git a/homeassistant/components/trafikverket_train/util.py b/homeassistant/components/trafikverket_train/util.py index 6ed672c9e7e..c5553c4a4a7 100644 --- a/homeassistant/components/trafikverket_train/util.py +++ b/homeassistant/components/trafikverket_train/util.py @@ -1,7 +1,9 @@ """Utils for trafikverket_train.""" from __future__ import annotations -from datetime import time +from datetime import date, time, timedelta + +from homeassistant.const import WEEKDAYS def create_unique_id( @@ -13,3 +15,24 @@ def create_unique_id( f"{from_station.casefold().replace(' ', '')}-{to_station.casefold().replace(' ', '')}" f"-{timestr.casefold().replace(' ', '')}-{str(weekdays)}" ) + + +def next_weekday(fromdate: date, weekday: int) -> date: + """Return the date of the next time a specific weekday happen.""" + days_ahead = weekday - fromdate.weekday() + if days_ahead <= 0: + days_ahead += 7 + return fromdate + timedelta(days_ahead) + + +def next_departuredate(departure: list[str]) -> date: + """Calculate the next departuredate from an array input of short days.""" + today_date = date.today() + today_weekday = date.weekday(today_date) + if WEEKDAYS[today_weekday] in departure: + return today_date + for day in departure: + next_departure = WEEKDAYS.index(day) + if next_departure > today_weekday: + return next_weekday(today_date, next_departure) + return next_weekday(today_date, WEEKDAYS.index(departure[0])) diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 014637b99f6..8c46afa5972 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.3"] + "requirements": ["pytrafikverket==0.3.5"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index f34eae3cf1f..3ec7d137b6e 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -20,8 +20,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 43a37179b03..7e02c3d419d 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,7 +1,8 @@ """Support for the Transmission BitTorrent client API.""" from __future__ import annotations -from datetime import timedelta +from collections.abc import Callable +from datetime import datetime, timedelta from functools import partial import logging import re @@ -13,6 +14,7 @@ from transmission_rpc.error import ( TransmissionConnectError, TransmissionError, ) +from transmission_rpc.session import SessionStats import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -132,17 +134,25 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) - client = TransmissionClient(hass, config_entry) + try: + api = await get_api(hass, dict(config_entry.data)) + except CannotConnect as error: + raise ConfigEntryNotReady from error + except (AuthenticationError, UnknownError) as error: + raise ConfigEntryAuthFailed from error + + client = TransmissionClient(hass, config_entry, api) + await client.async_setup() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = client - await client.async_setup() - + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + client.register_services() return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Transmission Entry from config_entry.""" - client = hass.data[DOMAIN].pop(config_entry.entry_id) + client: TransmissionClient = hass.data[DOMAIN].pop(config_entry.entry_id) if client.unsub_timer: client.unsub_timer() @@ -159,7 +169,9 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -async def get_api(hass, entry): +async def get_api( + hass: HomeAssistant, entry: dict[str, Any] +) -> transmission_rpc.Client: """Get Transmission client.""" host = entry[CONF_HOST] port = entry[CONF_PORT] @@ -205,13 +217,18 @@ def _get_client(hass: HomeAssistant, data: dict[str, Any]) -> TransmissionClient class TransmissionClient: """Transmission Client Object.""" - def __init__(self, hass, config_entry): + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: transmission_rpc.Client, + ) -> None: """Initialize the Transmission RPC API.""" self.hass = hass self.config_entry = config_entry - self.tm_api: transmission_rpc.Client = None - self._tm_data: TransmissionData = None - self.unsub_timer = None + self.tm_api = api + self._tm_data = TransmissionData(hass, config_entry, api) + self.unsub_timer: Callable[[], None] | None = None @property def api(self) -> TransmissionData: @@ -220,24 +237,13 @@ class TransmissionClient: async def async_setup(self) -> None: """Set up the Transmission client.""" - - try: - self.tm_api = await get_api(self.hass, self.config_entry.data) - except CannotConnect as error: - raise ConfigEntryNotReady from error - except (AuthenticationError, UnknownError) as error: - raise ConfigEntryAuthFailed from error - - self._tm_data = TransmissionData(self.hass, self.config_entry, self.tm_api) - - await self.hass.async_add_executor_job(self._tm_data.init_torrent_list) - await self.hass.async_add_executor_job(self._tm_data.update) + await self.hass.async_add_executor_job(self.api.init_torrent_list) + await self.hass.async_add_executor_job(self.api.update) self.add_options() self.set_scan_interval(self.config_entry.options[CONF_SCAN_INTERVAL]) - await self.hass.config_entries.async_forward_entry_setups( - self.config_entry, PLATFORMS - ) + def register_services(self) -> None: + """Register integration services.""" def add_torrent(service: ServiceCall) -> None: """Add new torrent to download.""" @@ -328,12 +334,12 @@ class TransmissionClient: self.config_entry, options=options ) - def set_scan_interval(self, scan_interval): + def set_scan_interval(self, scan_interval: float) -> None: """Update scan interval.""" - def refresh(event_time): + def refresh(event_time: datetime) -> None: """Get the latest data from Transmission.""" - self._tm_data.update() + self.api.update() if self.unsub_timer is not None: self.unsub_timer() @@ -344,7 +350,7 @@ class TransmissionClient: @staticmethod async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: """Triggered by config entry options updates.""" - tm_client = hass.data[DOMAIN][entry.entry_id] + tm_client: TransmissionClient = hass.data[DOMAIN][entry.entry_id] tm_client.set_scan_interval(entry.options[CONF_SCAN_INTERVAL]) await hass.async_add_executor_job(tm_client.api.update) @@ -358,22 +364,22 @@ class TransmissionData: """Initialize the Transmission RPC API.""" self.hass = hass self.config = config - self.data: transmission_rpc.Session = None - self.available: bool = True - self._all_torrents: list[transmission_rpc.Torrent] = [] self._api: transmission_rpc.Client = api + self.data: SessionStats | None = None + self.available: bool = True + self._session: transmission_rpc.Session | None = None + self._all_torrents: list[transmission_rpc.Torrent] = [] self._completed_torrents: list[transmission_rpc.Torrent] = [] - self._session: transmission_rpc.Session = None self._started_torrents: list[transmission_rpc.Torrent] = [] self._torrents: list[transmission_rpc.Torrent] = [] @property - def host(self): + def host(self) -> str: """Return the host name.""" return self.config.data[CONF_HOST] @property - def signal_update(self): + def signal_update(self) -> str: """Update signal per transmission entry.""" return f"{DATA_UPDATED}-{self.host}" @@ -382,7 +388,7 @@ class TransmissionData: """Get the list of torrents.""" return self._torrents - def update(self): + def update(self) -> None: """Get the latest data from Transmission instance.""" try: self.data = self._api.session_stats() @@ -400,7 +406,7 @@ class TransmissionData: _LOGGER.error("Unable to connect to Transmission client %s", self.host) dispatcher_send(self.hass, self.signal_update) - def init_torrent_list(self): + def init_torrent_list(self) -> None: """Initialize torrent lists.""" self._torrents = self._api.get_torrents() self._completed_torrents = [ @@ -410,7 +416,7 @@ class TransmissionData: torrent for torrent in self._torrents if torrent.status == "downloading" ] - def check_completed_torrent(self): + def check_completed_torrent(self) -> None: """Get completed torrent functionality.""" old_completed_torrent_names = { torrent.name for torrent in self._completed_torrents @@ -428,7 +434,7 @@ class TransmissionData: self._completed_torrents = current_completed_torrents - def check_started_torrent(self): + def check_started_torrent(self) -> None: """Get started torrent functionality.""" old_started_torrent_names = {torrent.name for torrent in self._started_torrents} @@ -444,7 +450,7 @@ class TransmissionData: self._started_torrents = current_started_torrents - def check_removed_torrent(self): + def check_removed_torrent(self) -> None: """Get removed torrent functionality.""" current_torrent_names = {torrent.name for torrent in self._torrents} @@ -456,24 +462,24 @@ class TransmissionData: self._all_torrents = self._torrents.copy() - def start_torrents(self): + def start_torrents(self) -> None: """Start all torrents.""" if not self._torrents: return self._api.start_all() - def stop_torrents(self): + def stop_torrents(self) -> None: """Stop all active torrents.""" if not self._torrents: return torrent_ids = [torrent.id for torrent in self._torrents] self._api.stop_torrent(torrent_ids) - def set_alt_speed_enabled(self, is_enabled): + def set_alt_speed_enabled(self, is_enabled: bool) -> None: """Set the alternative speed flag.""" self._api.set_session(alt_speed_enabled=is_enabled) - def get_alt_speed_enabled(self): + def get_alt_speed_enabled(self) -> bool | None: """Get the alternative speed flag.""" if self._session is None: return None diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index b7784fbe4a9..d1005f5e84c 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -57,7 +57,9 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return TransmissionOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} @@ -141,7 +143,9 @@ class TransmissionOptionsFlowHandler(config_entries.OptionsFlow): """Initialize Transmission 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: """Manage the Transmission options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 833c1910d4e..93bea8a25c9 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from contextlib import suppress +from typing import Any from transmission_rpc.torrent import Torrent @@ -9,10 +10,10 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import TransmissionClient from .const import ( @@ -34,8 +35,8 @@ async def async_setup_entry( ) -> None: """Set up the Transmission sensors.""" - tm_client = hass.data[DOMAIN][config_entry.entry_id] - name = config_entry.data[CONF_NAME] + tm_client: TransmissionClient = hass.data[DOMAIN][config_entry.entry_id] + name: str = config_entry.data[CONF_NAME] dev = [ TransmissionSpeedSensor( @@ -97,12 +98,18 @@ class TransmissionSensor(SensorEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, tm_client, client_name, sensor_translation_key, key): + def __init__( + self, + tm_client: TransmissionClient, + client_name: str, + sensor_translation_key: str, + key: str, + ) -> None: """Initialize the sensor.""" - self._tm_client: TransmissionClient = tm_client + self._tm_client = tm_client self._attr_translation_key = sensor_translation_key self._key = key - self._state = None + self._state: StateType = None self._attr_unique_id = f"{tm_client.config_entry.entry_id}-{key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -112,7 +119,7 @@ class TransmissionSensor(SensorEntity): ) @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" return self._state @@ -193,12 +200,12 @@ class TransmissionTorrentsSensor(TransmissionSensor): } @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" return "Torrents" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes, if any.""" info = _torrents_info( torrents=self._tm_client.api.torrents, @@ -218,7 +225,9 @@ class TransmissionTorrentsSensor(TransmissionSensor): self._state = len(torrents) -def _filter_torrents(torrents: list[Torrent], statuses=None) -> list[Torrent]: +def _filter_torrents( + torrents: list[Torrent], statuses: list[str] | None = None +) -> list[Torrent]: return [ torrent for torrent in torrents @@ -226,7 +235,9 @@ def _filter_torrents(torrents: list[Torrent], statuses=None) -> list[Torrent]: ] -def _torrents_info(torrents, order, limit, statuses=None): +def _torrents_info( + torrents: list[Torrent], order: str, limit: int, statuses: list[str] | None = None +) -> dict[str, Any]: infos = {} torrents = _filter_torrents(torrents, statuses) torrents = SUPPORTED_ORDER_MODES[order](torrents) diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 89f89e079fa..fad099fc5b9 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -1,4 +1,5 @@ """Support for setting the Transmission BitTorrent client Turtle Mode.""" +from collections.abc import Callable import logging from typing import Any @@ -6,11 +7,11 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import TransmissionClient from .const import DOMAIN, SWITCH_TYPES _LOGGING = logging.getLogger(__name__) @@ -23,8 +24,8 @@ async def async_setup_entry( ) -> None: """Set up the Transmission switch.""" - tm_client = hass.data[DOMAIN][config_entry.entry_id] - name = config_entry.data[CONF_NAME] + tm_client: TransmissionClient = hass.data[DOMAIN][config_entry.entry_id] + name: str = config_entry.data[CONF_NAME] dev = [] for switch_type, switch_name in SWITCH_TYPES.items(): @@ -39,14 +40,20 @@ class TransmissionSwitch(SwitchEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, switch_type, switch_name, tm_client, client_name): + def __init__( + self, + switch_type: str, + switch_name: str, + tm_client: TransmissionClient, + client_name: str, + ) -> None: """Initialize the Transmission switch.""" self._attr_name = switch_name self.type = switch_type self._tm_client = tm_client self._state = STATE_OFF self._data = None - self.unsub_update = None + self.unsub_update: Callable[[], None] | None = None self._attr_unique_id = f"{tm_client.config_entry.entry_id}-{switch_type}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -56,7 +63,7 @@ class TransmissionSwitch(SwitchEntity): ) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._state == STATE_ON @@ -94,10 +101,10 @@ class TransmissionSwitch(SwitchEntity): ) @callback - def _schedule_immediate_update(self): + def _schedule_immediate_update(self) -> None: self.async_schedule_update_ha_state(True) - async def will_remove_from_hass(self): + async def will_remove_from_hass(self) -> None: """Unsubscribe from update dispatcher.""" if self.unsub_update: self.unsub_update() diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 2b28a7e4e5e..509e7e17013 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -234,10 +234,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class DeviceListener(TuyaDeviceListener): """Device Update Listener.""" - # pylint: disable=arguments-differ - # Library incorrectly defines methods as 'classmethod' - # https://github.com/tuya/tuya-iot-python-sdk/pull/48 - def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 998e5a55e63..3aae417aac7 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -9,8 +9,9 @@ from typing import Any, Literal, Self, overload from tuya_iot import TuyaDevice, TuyaDeviceManager +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType from .util import remap_value diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 90cf4266ae6..289e319df1b 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -8,7 +8,7 @@ from tuya_iot import TuyaHomeManager, TuyaScene from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py index 5a1a1758d3e..5c1d71fa03b 100644 --- a/homeassistant/components/twentemilieu/entity.py +++ b/homeassistant/components/twentemilieu/entity.py @@ -7,8 +7,8 @@ from twentemilieu import WasteType from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 2a071bb6966..5ddd22c8a23 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py index eb83fe490e7..f5479917064 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -9,8 +9,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 4d11a690f35..10959b8965c 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -50,7 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data[UNIFI_DOMAIN][config_entry.entry_id] = controller await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - await controller.async_update_device_registry() + controller.async_update_device_registry() if len(hass.data[UNIFI_DOMAIN]) == 1: async_setup_services(hass) diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py new file mode 100644 index 00000000000..0235f6156cc --- /dev/null +++ b/homeassistant/components/unifi/button.py @@ -0,0 +1,111 @@ +"""Button platform for UniFi Network integration. + +Support for restarting UniFi devices. +""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any, Generic + +import aiounifi +from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItemT +from aiounifi.models.device import Device, DeviceRestartRequest + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +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 .const import DOMAIN as UNIFI_DOMAIN +from .controller import UniFiController +from .entity import ( + HandlerT, + UnifiEntity, + UnifiEntityDescription, + async_device_available_fn, + async_device_device_info_fn, +) + + +@callback +async def async_restart_device_control_fn( + api: aiounifi.Controller, obj_id: str +) -> None: + """Restart device.""" + await api.request(DeviceRestartRequest.create(obj_id)) + + +@dataclass +class UnifiButtonEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): + """Validate and load entities from different UniFi handlers.""" + + control_fn: Callable[[aiounifi.Controller, str], Coroutine[Any, Any, None]] + + +@dataclass +class UnifiButtonEntityDescription( + ButtonEntityDescription, + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiButtonEntityDescriptionMixin[HandlerT, ApiItemT], +): + """Class describing UniFi button entity.""" + + +ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( + UnifiButtonEntityDescription[Devices, Device]( + key="Device restart", + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + device_class=ButtonDeviceClass.RESTART, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + control_fn=async_restart_device_control_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda _: "Restart", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"device_restart-{obj_id}", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button platform for UniFi Network integration.""" + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + if not controller.is_admin: + return + + controller.register_platform_add_entities( + UnifiButtonEntity, ENTITY_DESCRIPTIONS, async_add_entities + ) + + +class UnifiButtonEntity(UnifiEntity[HandlerT, ApiItemT], ButtonEntity): + """Base representation of a UniFi image.""" + + entity_description: UnifiButtonEntityDescription[HandlerT, ApiItemT] + + async def async_press(self) -> None: + """Press the button.""" + await self.entity_description.control_fn(self.controller.api, self._obj_id) + + @callback + def async_update_state(self, event: ItemEvent, obj_id: str) -> None: + """Update entity state.""" diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 12f2d49e416..8c0696463c5 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -13,6 +13,7 @@ from types import MappingProxyType from typing import Any from urllib.parse import urlparse +from aiounifi.interfaces.sites import Sites import voluptuous as vol from homeassistant import config_entries @@ -63,6 +64,8 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): VERSION = 1 + sites: Sites + @staticmethod @callback def async_get_options_flow( @@ -74,8 +77,6 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): def __init__(self) -> None: """Initialize the UniFi Network flow.""" self.config: dict[str, Any] = {} - self.site_ids: dict[str, str] = {} - self.site_names: dict[str, str] = {} self.reauth_config_entry: config_entries.ConfigEntry | None = None self.reauth_schema: dict[vol.Marker, Any] = {} @@ -99,7 +100,8 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): controller = await get_unifi_controller( self.hass, MappingProxyType(self.config) ) - sites = await controller.sites() + await controller.sites.update() + self.sites = controller.sites except AuthenticationRequired: errors["base"] = "faulty_credentials" @@ -108,12 +110,10 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): errors["base"] = "service_unavailable" else: - self.site_ids = {site["_id"]: site["name"] for site in sites.values()} - self.site_names = {site["_id"]: site["desc"] for site in sites.values()} - if ( self.reauth_config_entry - and self.reauth_config_entry.unique_id in self.site_names + and self.reauth_config_entry.unique_id is not None + and self.reauth_config_entry.unique_id in self.sites ): return await self.async_step_site( {CONF_SITE_ID: self.reauth_config_entry.unique_id} @@ -148,7 +148,7 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): """Select site to control.""" if user_input is not None: unique_id = user_input[CONF_SITE_ID] - self.config[CONF_SITE_ID] = self.site_ids[unique_id] + self.config[CONF_SITE_ID] = self.sites[unique_id].name config_entry = await self.async_set_unique_id(unique_id) abort_reason = "configuration_updated" @@ -171,19 +171,16 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): await self.hass.config_entries.async_reload(config_entry.entry_id) return self.async_abort(reason=abort_reason) - site_nice_name = self.site_names[unique_id] + site_nice_name = self.sites[unique_id].description return self.async_create_entry(title=site_nice_name, data=self.config) - if len(self.site_names) == 1: - return await self.async_step_site( - {CONF_SITE_ID: next(iter(self.site_names))} - ) + if len(self.sites.values()) == 1: + return await self.async_step_site({CONF_SITE_ID: next(iter(self.sites))}) + site_names = {site.site_id: site.description for site in self.sites.values()} return self.async_show_form( step_id="site", - data_schema=vol.Schema( - {vol.Required(CONF_SITE_ID): vol.In(self.site_names)} - ), + data_schema=vol.Schema({vol.Required(CONF_SITE_ID): vol.In(site_names)}), ) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index e03bd50d483..176511645aa 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -8,6 +8,7 @@ LOGGER = logging.getLogger(__package__) DOMAIN = "unifi" PLATFORMS = [ + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.IMAGE, Platform.SENSOR, diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 6ac4e622736..ba188f80135 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,8 +10,9 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.models.configuration import Configuration +from aiounifi.models.device import DeviceSetPoePortModeRequest from aiounifi.websocket import WebsocketState -import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,14 +29,18 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import ( + DeviceEntry, + DeviceEntryType, + DeviceInfo, +) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_config_entry -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later, async_track_time_interval import homeassistant.util.dt as dt_util from .const import ( @@ -88,9 +93,8 @@ class UniFiController: self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] - self.site_id: str = "" - self._site_name: str | None = None - self._site_role: str | None = None + self.site = config_entry.data[CONF_SITE_ID] + self.is_admin = False self._cancel_heartbeat_check: CALLBACK_TYPE | None = None self._heartbeat_time: dict[str, datetime] = {} @@ -100,6 +104,9 @@ class UniFiController: self.entities: dict[str, str] = {} self.known_objects: set[tuple[str, str]] = set() + self.poe_command_queue: dict[str, dict[int, str]] = {} + self._cancel_poe_command: CALLBACK_TYPE | None = None + def load_config_entry_options(self) -> None: """Store attributes to avoid property call overhead since they are called frequently.""" options = self.config_entry.options @@ -155,30 +162,6 @@ class UniFiController: host: str = self.config_entry.data[CONF_HOST] return host - @property - def site(self) -> str: - """Return the site of this config entry.""" - site_id: str = self.config_entry.data[CONF_SITE_ID] - return site_id - - @property - def site_name(self) -> str | None: - """Return the nice name of site.""" - return self._site_name - - @property - def site_role(self) -> str | None: - """Return the site user role of this controller.""" - return self._site_role - - @property - def mac(self) -> str | None: - """Return the mac address of this controller.""" - for client in self.api.clients.values(): - if self.host == client.ip: - return client.mac - return None - @callback def register_platform_add_entities( self, @@ -265,15 +248,8 @@ class UniFiController: """Set up a UniFi Network instance.""" await self.api.initialize() - sites = await self.api.sites() - for site in sites.values(): - if self.site == site["name"]: - self.site_id = site["_id"] - self._site_name = site["desc"] - break - - description = await self.api.site_description() - self._site_role = description[0]["site_role"] + assert self.config_entry.unique_id is not None + self.is_admin = self.api.sites[self.config_entry.unique_id].role == "admin" # Restore clients that are not a part of active clients list. entity_registry = er.async_get(self.hass) @@ -336,19 +312,55 @@ class UniFiController: for unique_id in unique_ids_to_remove: del self._heartbeat_time[unique_id] - async def async_update_device_registry(self) -> None: + @callback + def async_queue_poe_port_command( + self, device_id: str, port_idx: int, poe_mode: str + ) -> None: + """Queue commands to execute them together per device.""" + if self._cancel_poe_command: + self._cancel_poe_command() + self._cancel_poe_command = None + + device_queue = self.poe_command_queue.setdefault(device_id, {}) + device_queue[port_idx] = poe_mode + + async def async_execute_command(now: datetime) -> None: + """Execute previously queued commands.""" + queue = self.poe_command_queue.copy() + self.poe_command_queue.clear() + for device_id, device_commands in queue.items(): + device = self.api.devices[device_id] + commands = [(idx, mode) for idx, mode in device_commands.items()] + await self.api.request( + DeviceSetPoePortModeRequest.create(device, targets=commands) + ) + + self._cancel_poe_command = async_call_later(self.hass, 5, async_execute_command) + + @property + def device_info(self) -> DeviceInfo: + """UniFi controller device info.""" + assert self.config_entry.unique_id is not None + + version: str | None = None + if sysinfo := next(iter(self.api.system_information.values()), None): + version = sysinfo.version + + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(UNIFI_DOMAIN, self.config_entry.unique_id)}, + manufacturer=ATTR_MANUFACTURER, + model="UniFi Network Application", + name="UniFi Network", + sw_version=version, + ) + + @callback + def async_update_device_registry(self) -> DeviceEntry: """Update device registry.""" - if self.mac is None: - return - device_registry = dr.async_get(self.hass) - - device_registry.async_get_or_create( - config_entry_id=self.config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, self.mac)}, - default_manufacturer=ATTR_MANUFACTURER, - default_model="UniFi Network", - default_name="UniFi Network", + return device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, **self.device_info ) @staticmethod @@ -375,7 +387,7 @@ class UniFiController: async def async_reconnect(self) -> None: """Try to reconnect UniFi Network session.""" try: - async with async_timeout.timeout(5): + async with asyncio.timeout(5): await self.api.login() self.api.start_websocket() @@ -414,6 +426,10 @@ class UniFiController: self._cancel_heartbeat_check() self._cancel_heartbeat_check = None + if self._cancel_poe_command: + self._cancel_poe_command() + self._cancel_poe_command = None + return True @@ -434,18 +450,19 @@ async def get_unifi_controller( ) controller = aiounifi.Controller( - host=config[CONF_HOST], - username=config[CONF_USERNAME], - password=config[CONF_PASSWORD], - port=config[CONF_PORT], - site=config[CONF_SITE_ID], - websession=session, - ssl_context=ssl_context, + Configuration( + session, + host=config[CONF_HOST], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + port=config[CONF_PORT], + site=config[CONF_SITE_ID], + ssl_context=ssl_context, + ) ) try: - async with async_timeout.timeout(10): - await controller.check_unifi_os() + async with asyncio.timeout(10): await controller.login() return controller diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index 3c72c06d6f2..c01dc193078 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -94,7 +94,7 @@ async def async_get_config_entry_diagnostics( diag["config"] = async_redact_data( async_replace_dict_data(config_entry.as_dict(), macs_to_redact), REDACT_CONFIG ) - diag["site_role"] = controller.site_role + diag["role_is_admin"] = controller.is_admin diag["clients"] = { macs_to_redact[k]: async_redact_data( async_replace_dict_data(v.raw, macs_to_redact), REDACT_CLIENTS diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 54b9cb12157..28a7b557b16 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -21,9 +21,10 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceEntryType, + DeviceInfo, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import Entity, EntityDescription from .const import ATTR_MANUFACTURER, DOMAIN @@ -45,12 +46,19 @@ def async_device_available_fn(controller: UniFiController, obj_id: str) -> bool: @callback -def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: +def async_wlan_available_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if WLAN is available.""" + wlan = controller.api.wlans[obj_id] + return controller.available and wlan.enabled + + +@callback +def async_device_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: """Create device registry entry for device.""" if "_" in obj_id: # Sub device (outlet or port) obj_id = obj_id.partition("_")[0] - device = api.devices[obj_id] + device = controller.api.devices[obj_id] return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, device.mac)}, manufacturer=ATTR_MANUFACTURER, @@ -62,9 +70,9 @@ def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @callback -def async_wlan_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: +def async_wlan_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: """Create device registry entry for WLAN.""" - wlan = api.wlans[obj_id] + wlan = controller.api.wlans[obj_id] return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, wlan.id)}, @@ -74,6 +82,17 @@ def async_wlan_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceIn ) +@callback +def async_client_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: + """Create device registry entry for client.""" + client = controller.api.clients[obj_id] + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, obj_id)}, + default_manufacturer=client.oui, + default_name=client.name or client.hostname, + ) + + @dataclass class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -81,7 +100,7 @@ class UnifiDescription(Generic[HandlerT, ApiItemT]): allowed_fn: Callable[[UniFiController, str], bool] api_handler_fn: Callable[[aiounifi.Controller], HandlerT] available_fn: Callable[[UniFiController, str], bool] - device_info_fn: Callable[[aiounifi.Controller, str], DeviceInfo | None] + device_info_fn: Callable[[UniFiController, str], DeviceInfo | None] event_is_on: tuple[EventKey, ...] | None event_to_subscribe: tuple[EventKey, ...] | None name_fn: Callable[[ApiItemT], str | None] @@ -118,7 +137,7 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): self._removed = False self._attr_available = description.available_fn(controller, obj_id) - self._attr_device_info = description.device_info_fn(controller.api, obj_id) + self._attr_device_info = description.device_info_fn(controller, obj_id) self._attr_should_poll = description.should_poll self._attr_unique_id = description.unique_id_fn(controller, obj_id) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index c3969c21bc4..8231b87ee85 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -26,6 +26,7 @@ from .entity import ( HandlerT, UnifiEntity, UnifiEntityDescription, + async_wlan_available_fn, async_wlan_device_info_fn, ) @@ -61,11 +62,11 @@ ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = ( entity_registry_enabled_default=False, allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, - available_fn=lambda controller, _: controller.available, + available_fn=async_wlan_available_fn, device_info_fn=async_wlan_device_info_fn, event_is_on=None, event_to_subscribe=None, - name_fn=lambda _: "QR Code", + name_fn=lambda wlan: "QR Code", object_fn=lambda api, obj_id: api.wlans[obj_id], should_poll=False, supported_fn=lambda controller, obj_id: True, @@ -84,7 +85,7 @@ async def async_setup_entry( """Set up image platform for UniFi Network integration.""" controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - if controller.site_role != "admin": + if not controller.is_admin: return controller.register_platform_add_entities( diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 3b1fa68638b..f20e5f9e4ac 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==52"], + "requirements": ["aiounifi==61"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index ff5c58d7d6c..142bd587853 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -10,13 +10,16 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Generic -import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients +from aiounifi.interfaces.devices import Devices +from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client +from aiounifi.models.device import Device +from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.wlan import Wlan @@ -28,8 +31,6 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfPower from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -39,8 +40,10 @@ from .entity import ( HandlerT, UnifiEntity, UnifiEntityDescription, + async_client_device_info_fn, async_device_available_fn, async_device_device_info_fn, + async_wlan_available_fn, async_wlan_device_info_fn, ) @@ -86,14 +89,19 @@ def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: @callback -def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: - """Create device registry entry for client.""" - client = api.clients[obj_id] - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, obj_id)}, - default_manufacturer=client.oui, - default_name=client.name or client.hostname, - ) +def async_device_outlet_power_supported_fn( + controller: UniFiController, obj_id: str +) -> bool: + """Determine if an outlet has the power property.""" + # At this time, an outlet_caps value of 3 is expected to indicate that the outlet + # supports metering + return controller.api.outlets[obj_id].caps == 3 + + +@callback +def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) -> bool: + """Determine if a device supports reading overall power metrics.""" + return controller.api.devices[obj_id].outlet_ac_power_budget is not None @dataclass @@ -192,19 +200,78 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="WLAN clients", entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, - allowed_fn=lambda controller, _: True, + allowed_fn=lambda controller, obj_id: True, api_handler_fn=lambda api: api.wlans, - available_fn=lambda controller, obj_id: controller.available, + available_fn=async_wlan_available_fn, device_info_fn=async_wlan_device_info_fn, event_is_on=None, event_to_subscribe=None, - name_fn=lambda client: None, + name_fn=lambda wlan: None, object_fn=lambda api, obj_id: api.wlans[obj_id], should_poll=True, - supported_fn=lambda controller, _: True, + supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"wlan_clients-{obj_id}", value_fn=async_wlan_client_value_fn, ), + UnifiSensorEntityDescription[Outlets, Outlet]( + key="Outlet power metering", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.outlets, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda outlet: f"{outlet.name} Outlet Power", + object_fn=lambda api, obj_id: api.outlets[obj_id], + should_poll=True, + supported_fn=async_device_outlet_power_supported_fn, + unique_id_fn=lambda controller, obj_id: f"outlet_power-{obj_id}", + value_fn=lambda _, obj: obj.power if obj.relay_state else "0", + ), + UnifiSensorEntityDescription[Devices, Device]( + key="SmartPower AC power budget", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "AC Power Budget", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=async_device_outlet_supported_fn, + unique_id_fn=lambda controller, obj_id: f"ac_power_budget-{obj_id}", + value_fn=lambda controller, device: device.outlet_ac_power_budget, + ), + UnifiSensorEntityDescription[Devices, Device]( + key="SmartPower AC power consumption", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "AC Power Consumption", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=async_device_outlet_supported_fn, + unique_id_fn=lambda controller, obj_id: f"ac_power_conumption-{obj_id}", + value_fn=lambda controller, device: device.outlet_ac_power_consumption, + ), ) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 64e3ec2455c..560e150e63c 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -17,19 +17,18 @@ from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets +from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client, ClientBlockRequest -from aiounifi.models.device import ( - DeviceSetOutletRelayRequest, - DeviceSetPoePortModeRequest, -) +from aiounifi.models.device import DeviceSetOutletRelayRequest from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup from aiounifi.models.event import Event, EventKey from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port +from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest from homeassistant.components.switch import ( @@ -41,11 +40,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceEntryType, -) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN @@ -55,6 +50,7 @@ from .entity import ( SubscriptionT, UnifiEntity, UnifiEntityDescription, + async_client_device_info_fn, async_device_available_fn, async_device_device_info_fn, async_wlan_device_info_fn, @@ -78,18 +74,9 @@ def async_dpi_group_is_on_fn( @callback -def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: - """Create device registry entry for client.""" - client = api.clients[obj_id] - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, obj_id)}, - default_manufacturer=client.oui, - default_name=client.name or client.hostname, - ) - - -@callback -def async_dpi_group_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: +def async_dpi_group_device_info_fn( + controller: UniFiController, obj_id: str +) -> DeviceInfo: """Create device registry entry for DPI group.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -100,59 +87,95 @@ def async_dpi_group_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Dev ) +@callback +def async_port_forward_device_info_fn( + controller: UniFiController, obj_id: str +) -> DeviceInfo: + """Create device registry entry for port forward.""" + unique_id = controller.config_entry.unique_id + assert unique_id is not None + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + model="UniFi Network", + name="UniFi Network", + ) + + async def async_block_client_control_fn( - api: aiounifi.Controller, obj_id: str, target: bool + controller: UniFiController, obj_id: str, target: bool ) -> None: """Control network access of client.""" - await api.request(ClientBlockRequest.create(obj_id, not target)) + await controller.api.request(ClientBlockRequest.create(obj_id, not target)) async def async_dpi_group_control_fn( - api: aiounifi.Controller, obj_id: str, target: bool + controller: UniFiController, obj_id: str, target: bool ) -> None: """Enable or disable DPI group.""" - dpi_group = api.dpi_groups[obj_id] + dpi_group = controller.api.dpi_groups[obj_id] await asyncio.gather( *[ - api.request(DPIRestrictionAppEnableRequest.create(app_id, target)) + controller.api.request( + DPIRestrictionAppEnableRequest.create(app_id, target) + ) for app_id in dpi_group.dpiapp_ids or [] ] ) +@callback +def async_outlet_supports_switching_fn( + controller: UniFiController, obj_id: str +) -> bool: + """Determine if an outlet supports switching.""" + outlet = controller.api.outlets[obj_id] + return outlet.has_relay or outlet.caps in (1, 3) + + async def async_outlet_control_fn( - api: aiounifi.Controller, obj_id: str, target: bool + controller: UniFiController, obj_id: str, target: bool ) -> None: """Control outlet relay.""" mac, _, index = obj_id.partition("_") - device = api.devices[mac] - await api.request(DeviceSetOutletRelayRequest.create(device, int(index), target)) + device = controller.api.devices[mac] + await controller.api.request( + DeviceSetOutletRelayRequest.create(device, int(index), target) + ) async def async_poe_port_control_fn( - api: aiounifi.Controller, obj_id: str, target: bool + controller: UniFiController, obj_id: str, target: bool ) -> None: """Control poe state.""" mac, _, index = obj_id.partition("_") - device = api.devices[mac] - port = api.ports[obj_id] + port = controller.api.ports[obj_id] on_state = "auto" if port.raw["poe_caps"] != 8 else "passthrough" state = on_state if target else "off" - await api.request(DeviceSetPoePortModeRequest.create(device, int(index), state)) + controller.async_queue_poe_port_command(mac, int(index), state) + + +async def async_port_forward_control_fn( + controller: UniFiController, obj_id: str, target: bool +) -> None: + """Control port forward state.""" + port_forward = controller.api.port_forwarding[obj_id] + await controller.api.request(PortForwardEnableRequest.create(port_forward, target)) async def async_wlan_control_fn( - api: aiounifi.Controller, obj_id: str, target: bool + controller: UniFiController, obj_id: str, target: bool ) -> None: """Control outlet relay.""" - await api.request(WlanEnableRequest.create(obj_id, target)) + await controller.api.request(WlanEnableRequest.create(obj_id, target)) @dataclass class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - control_fn: Callable[[aiounifi.Controller, str, bool], Coroutine[Any, Any, None]] + control_fn: Callable[[UniFiController, str, bool], Coroutine[Any, Any, None]] is_on_fn: Callable[[UniFiController, ApiItemT], bool] @@ -224,9 +247,29 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( name_fn=lambda outlet: outlet.name, object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=False, - supported_fn=lambda c, obj_id: c.api.outlets[obj_id].has_relay, + supported_fn=async_outlet_supports_switching_fn, unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-outlet-{obj_id.split('_', 1)[1]}", ), + UnifiSwitchEntityDescription[PortForwarding, PortForward]( + key="Port forward control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + icon="mdi:upload-network", + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.port_forwarding, + available_fn=lambda controller, obj_id: controller.available, + control_fn=async_port_forward_control_fn, + device_info_fn=async_port_forward_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + is_on_fn=lambda controller, port_forward: port_forward.enabled, + name_fn=lambda port_forward: f"{port_forward.name}", + object_fn=lambda api, obj_id: api.port_forwarding[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"port_forward-{obj_id}", + ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", device_class=SwitchDeviceClass.OUTLET, @@ -279,7 +322,7 @@ async def async_setup_entry( """Set up switches for UniFi Network integration.""" controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - if controller.site_role != "admin": + if not controller.is_admin: return for mac in controller.option_block_clients: @@ -309,15 +352,11 @@ class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self.entity_description.control_fn( - self.controller.api, self._obj_id, True - ) + await self.entity_description.control_fn(self.controller, self._obj_id, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self.entity_description.control_fn( - self.controller.api, self._obj_id, False - ) + await self.entity_description.control_fn(self.controller, self._obj_id, False) @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 661a9016bdc..6526a02da83 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -103,7 +103,7 @@ class UnifiDeviceUpdateEntity(UnifiEntity[_HandlerT, _DataT], UpdateEntity): def async_initiate_state(self) -> None: """Initiate entity state.""" self._attr_supported_features = UpdateEntityFeature.PROGRESS - if self.controller.site_role == "admin": + if self.controller.is_admin: self._attr_supported_features |= UpdateEntityFeature.INSTALL self.async_update_state(ItemEvent.ADDED, self._obj_id) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index a8a4c78465d..d42e611be7e 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -22,7 +22,8 @@ from pyunifiprotect.data import ( from homeassistant.core import callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import UNDEFINED from .const import ( diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index 0b3926f813f..4e1b003a504 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -4,7 +4,8 @@ import upb_lib from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, CONF_FILE_PATH, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import ( ATTR_ADDRESS, diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 4a3d970a068..318ba44f557 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -4,7 +4,6 @@ from contextlib import suppress import logging from urllib.parse import urlparse -import async_timeout import upb_lib import voluptuous as vol @@ -44,8 +43,9 @@ async def _validate_input(data): upb.connect(_connected_callback) - with suppress(asyncio.TimeoutError), async_timeout.timeout(VALIDATE_TIMEOUT): - await connected_event.wait() + with suppress(asyncio.TimeoutError): + async with asyncio.timeout(VALIDATE_TIMEOUT): + await connected_event.wait() upb.disconnect() diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index cd356925de1..174d35f07e0 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -21,12 +21,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -123,10 +122,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.async_add_executor_job(manager.authenticate) except upcloud_api.UpCloudAPIError: - _LOGGER.error("Authentication failed", exc_info=True) + _LOGGER.exception("Authentication failed") return False except requests.exceptions.RequestException as err: - _LOGGER.error("Failed to connect", exc_info=True) + _LOGGER.exception("Failed to connect") raise ConfigEntryNotReady from err if entry.options.get(CONF_SCAN_INTERVAL): @@ -244,7 +243,7 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): assert self.coordinator.config_entry is not None return DeviceInfo( configuration_url="https://hub.upcloud.com", - default_model="Control Panel", + model="Control Panel", entry_type=DeviceEntryType.SERVICE, identifiers={ (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index b9d01629536..e23032e24fe 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -216,6 +216,13 @@ class UpdateEntity(RestoreEntity): """Version installed and in use.""" return self._attr_installed_version + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For updates this is True if the entity has a device class. + """ + return self.device_class is not None + @property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 5f77d58c5ea..326ff5d7651 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import timedelta -import async_timeout from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp @@ -44,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) udn = entry.data[CONFIG_ENTRY_UDN] - st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name + st = entry.data[CONFIG_ENTRY_ST] usn = f"{udn}::{st}" # Register device discovered-callback. @@ -71,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await device_discovered_event.wait() except asyncio.TimeoutError as err: raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index a3d7709a5d5..e53d89018fb 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -3,7 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import UpnpDataUpdateCoordinator diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 4b4f0358bb9..95bb3e77966 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.34.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.0", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 56d570110b0..55faf7ccb3a 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -4,8 +4,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index d5caf36fa18..3057bd7c220 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -3,8 +3,8 @@ from __future__ import annotations from pyuptimerobot import UptimeRobotMonitor -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import UptimeRobotDataUpdateCoordinator diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index cf2a6da9e08..64b271d4200 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -7,11 +7,8 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, -) -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 7301158d6c6..f3e86136f5d 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -32,8 +32,8 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( EventStateChangedData, diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 473b9fa07d1..1feda8e694a 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index ef0cef938b1..25591cc1cb0 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -18,10 +18,9 @@ async def async_setup_entry( """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - entities = [] - for channel in cntrl.get_all("binary_sensor"): - entities.append(VelbusBinarySensor(channel)) - async_add_entities(entities) + async_add_entities( + VelbusBinarySensor(channel) for channel in cntrl.get_all("binary_sensor") + ) class VelbusBinarySensor(VelbusEntity, BinarySensorEntity): diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index d75486bab7a..2a0392c48cb 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -24,10 +24,7 @@ async def async_setup_entry( """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - entities = [] - for channel in cntrl.get_all("button"): - entities.append(VelbusButton(channel)) - async_add_entities(entities) + async_add_entities(VelbusButton(channel) for channel in cntrl.get_all("button")) class VelbusButton(VelbusEntity, ButtonEntity): @@ -37,6 +34,7 @@ class VelbusButton(VelbusEntity, ButtonEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.CONFIG + @api_call async def async_press(self) -> None: """Handle the button press.""" await self._channel.press() diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index ccdfb3b073b..ecdddd19289 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, PRESET_MODES -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -27,10 +27,7 @@ async def async_setup_entry( """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - entities = [] - for channel in cntrl.get_all("climate"): - entities.append(VelbusClimate(channel)) - async_add_entities(entities) + async_add_entities(VelbusClimate(channel) for channel in cntrl.get_all("climate")) class VelbusClimate(VelbusEntity, ClimateEntity): @@ -67,6 +64,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): """Return the current temperature.""" return self._channel.get_state() + @api_call async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: @@ -74,6 +72,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): await self._channel.set_temp(temp) self.async_write_ha_state() + @api_call async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the new preset mode.""" await self._channel.set_preset(PRESET_MODES[preset_mode]) diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 009c4fadfb9..46881fcdcaf 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -81,18 +81,22 @@ class VelbusCover(VelbusEntity, CoverEntity): return 100 - pos return None + @api_call async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._channel.open() + @api_call async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._channel.close() + @api_call async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._channel.stop() + @api_call async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self._channel.set_position(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 13ecb7febab..45220e1a9b4 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -1,9 +1,15 @@ """Support for Velbus devices.""" from __future__ import annotations +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate, ParamSpec, TypeVar + from velbusaio.channels import Channel as VelbusChannel -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -35,3 +41,25 @@ class VelbusEntity(Entity): async def _on_update(self) -> None: self.async_write_ha_state() + + +_T = TypeVar("_T", bound="VelbusEntity") +_P = ParamSpec("_P") + + +def api_call( + func: Callable[Concatenate[_T, _P], Awaitable[None]] +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch command exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except OSError as exc: + raise HomeAssistantError( + f"Could not execute {func.__name__} service for {self.name}" + ) from exc + + return cmd_wrapper diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index ca00a3134ce..1806c2905e9 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -63,6 +63,7 @@ class VelbusLight(VelbusEntity, LightEntity): """Return the brightness of the light.""" return int((self._channel.get_dimmer_state() * 255) / 100) + @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" if ATTR_BRIGHTNESS in kwargs: @@ -83,6 +84,7 @@ class VelbusLight(VelbusEntity, LightEntity): ) await getattr(self._channel, attr)(*args) + @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" attr, *args = ( @@ -113,6 +115,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity): """Return true if the light is on.""" return self._channel.is_on() + @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" if ATTR_FLASH in kwargs: @@ -126,6 +129,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity): attr, *args = "set_led_state", "on" await getattr(self._channel, attr)(*args) + @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" attr, *args = "set_led_state", "off" diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py index af79b5d1276..6e2b4d1a746 100644 --- a/homeassistant/components/velbus/select.py +++ b/homeassistant/components/velbus/select.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -37,6 +37,7 @@ class VelbusSelect(VelbusEntity, SelectEntity): self._attr_options = self._channel.get_options() self._attr_unique_id = f"{self._attr_unique_id}-program_select" + @api_call async def async_select_option(self, option: str) -> None: """Update the program on the module.""" await self._channel.set_selected_program(option) diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 6de8373d3fc..db7c165840e 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -36,10 +36,12 @@ class VelbusSwitch(VelbusEntity, SwitchEntity): """Return true if the switch is on.""" return self._channel.is_on() + @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the switch to turn on.""" await self._channel.turn_on() + @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the switch to turn off.""" await self._channel.turn_off() diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 4b2d2955832..a92d495f6af 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import update_coordinator -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP, VENSTAR_TIMEOUT @@ -128,6 +128,8 @@ class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): """Representation of a Venstar entity.""" + _attr_has_entity_name = True + def __init__( self, venstar_data_coordinator: VenstarDataUpdateCoordinator, diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py index 6104cd0d4f9..a5e15b04917 100644 --- a/homeassistant/components/venstar/binary_sensor.py +++ b/homeassistant/components/venstar/binary_sensor.py @@ -37,7 +37,7 @@ class VenstarBinarySensor(VenstarEntity, BinarySensorEntity): super().__init__(coordinator, config) self.alert = alert self._attr_unique_id = f"{config.entry_id}_{alert.replace(' ', '_')}" - self._attr_name = f"{self._client.name} {alert}" + self._attr_name = alert @property def is_on(self): diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index b4d3b6c6837..6359cc19e57 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -107,6 +107,7 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): _attr_fan_modes = [FAN_ON, FAN_AUTO] _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO] _attr_precision = PRECISION_HALVES + _attr_name = None def __init__( self, @@ -121,7 +122,6 @@ class VenstarThermostat(VenstarEntity, ClimateEntity): HVACMode.AUTO: self._client.MODE_AUTO, } self._attr_unique_id = config.entry_id - self._attr_name = self._client.name @property def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/venstar/config_flow.py b/homeassistant/components/venstar/config_flow.py index d97c5ada9e6..66ce22cb00b 100644 --- a/homeassistant/components/venstar/config_flow.py +++ b/homeassistant/components/venstar/config_flow.py @@ -1,8 +1,10 @@ """Config flow to configure the Venstar integration.""" +from typing import Any + from venstarcolortouch import VenstarColorTouch import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -10,21 +12,15 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType from .const import _LOGGER, DOMAIN, VENSTAR_TIMEOUT -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, - vol.Optional(CONF_PIN): str, - vol.Optional(CONF_SSL, default=False): bool, - } -) - -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str: """Validate the user input allows us to connect.""" username = data.get(CONF_USERNAME) password = data.get(CONF_PASSWORD) @@ -48,37 +44,48 @@ async def validate_input(hass: core.HomeAssistant, data): if not info_success: raise CannotConnect - return {"title": client.name} + return client.name -class VenstarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class VenstarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a venstar 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: """Create config entry. Show the setup form to the user.""" errors = {} - info = {} if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) try: - info = await validate_input(self.hass, user_input) + title = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry(title=title, data=user_input) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_PIN): str, + vol.Optional(CONF_SSL, default=False): bool, + } + ), + errors=errors, ) - async def async_step_import(self, import_data): + async def async_step_import(self, import_data: ConfigType) -> FlowResult: """Import entry from configuration.yaml.""" self._async_abort_entries_match({CONF_HOST: import_data[CONF_HOST]}) return await self.async_step_user( @@ -92,5 +99,5 @@ class VenstarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -class CannotConnect(exceptions.HomeAssistantError): +class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index e20c748f112..2d919bbc1bc 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -70,7 +70,7 @@ class VenstarSensorTypeMixin: """Mixin for sensor required keys.""" value_fn: Callable[[VenstarDataUpdateCoordinator, str], Any] - name_fn: Callable[[VenstarDataUpdateCoordinator, str], str] + name_fn: Callable[[str], str] uom_fn: Callable[[Any], str | None] @@ -156,7 +156,7 @@ class VenstarSensor(VenstarEntity, SensorEntity): @property def name(self): """Return the name of the device.""" - return self.entity_description.name_fn(self.coordinator, self.sensor_name) + return self.entity_description.name_fn(self.sensor_name) @property def native_value(self) -> int: @@ -178,7 +178,7 @@ SENSOR_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( value_fn=lambda coordinator, sensor_name: coordinator.client.get_sensor( sensor_name, "hum" ), - name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name} Humidity", + name_fn=lambda sensor_name: f"{sensor_name} Humidity", ), VenstarSensorEntityDescription( key="temp", @@ -188,7 +188,7 @@ SENSOR_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( value_fn=lambda coordinator, sensor_name: round( float(coordinator.client.get_sensor(sensor_name, "temp")), 1 ), - name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name.replace(' Temp', '')} Temperature", + name_fn=lambda sensor_name: f"{sensor_name.replace(' Temp', '')} Temperature", ), VenstarSensorEntityDescription( key="co2", @@ -198,7 +198,7 @@ SENSOR_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( value_fn=lambda coordinator, sensor_name: coordinator.client.get_sensor( sensor_name, "co2" ), - name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name} CO2", + name_fn=lambda sensor_name: f"{sensor_name} CO2", ), VenstarSensorEntityDescription( key="iaq", @@ -208,7 +208,7 @@ SENSOR_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( value_fn=lambda coordinator, sensor_name: coordinator.client.get_sensor( sensor_name, "iaq" ), - name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name} IAQ", + name_fn=lambda sensor_name: f"{sensor_name} IAQ", ), VenstarSensorEntityDescription( key="battery", @@ -218,7 +218,7 @@ SENSOR_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( value_fn=lambda coordinator, sensor_name: coordinator.client.get_sensor( sensor_name, "battery" ), - name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {sensor_name} Battery", + name_fn=lambda sensor_name: f"{sensor_name} Battery", ), ) @@ -227,7 +227,7 @@ RUNTIME_ENTITY = VenstarSensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, uom_fn=lambda _: UnitOfTime.MINUTES, value_fn=lambda coordinator, sensor_name: coordinator.runtimes[-1][sensor_name], - name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} {RUNTIME_ATTRIBUTES[sensor_name]} Runtime", + name_fn=lambda sensor_name: f"{RUNTIME_ATTRIBUTES[sensor_name]} Runtime", ) INFO_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( @@ -240,6 +240,6 @@ INFO_ENTITIES: tuple[VenstarSensorEntityDescription, ...] = ( value_fn=lambda coordinator, sensor_name: SCHEDULE_PARTS[ coordinator.client.get_info(sensor_name) ], - name_fn=lambda coordinator, sensor_name: f"{coordinator.client.name} Schedule Part", + name_fn=lambda _: "Schedule Part", ), ) diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 94e8d667d75..dfd9d9cdc04 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -5,14 +5,16 @@ from contextlib import suppress import os from pathlib import Path +from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR -from .const import DOMAIN +from .const import CONF_LOCK_DEFAULT_CODE, DOMAIN, LOGGER from .coordinator import VerisureDataUpdateCoordinator PLATFORMS = [ @@ -41,12 +43,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator + # Migrate lock default code from config entry to lock entity + # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Update options + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + # Propagate configuration change. + coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator.async_update_listeners() + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Verisure config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -72,3 +86,30 @@ def migrate_cookie_files(hass: HomeAssistant, entry: ConfigEntry) -> None: cookie_file.rename( hass.config.path(STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}") ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + if config_entry_default_code := entry.options.get(CONF_LOCK_DEFAULT_CODE): + entity_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entries: + if entity.entity_id.startswith("lock"): + entity_reg.async_update_entity_options( + entity.entity_id, + LOCK_DOMAIN, + {CONF_DEFAULT_CODE: config_entry_default_code}, + ) + new_options = entry.options.copy() + del new_options[CONF_LOCK_DEFAULT_CODE] + + hass.config_entries.async_update_entry(entry, options=new_options) + + entry.version = 2 + + LOGGER.info("Migration to version %s successful", entry.version) + + return True diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 284b8d6b00a..26e74cceb9e 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -11,7 +11,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 68d549eaa5d..cadb9b6788d 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -8,7 +8,8 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 90ad926aeb7..a240d45cf7e 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -10,7 +10,7 @@ from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -36,7 +36,6 @@ async def async_setup_entry( VerisureSmartcam.capture_smartcam.__name__, ) - assert hass.config.config_dir async_add_entities( VerisureSmartcam(coordinator, serial_number, hass.config.config_dir) for serial_number in coordinator.data["cameras"] diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 1fcf0eb9de2..d945463fa5e 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -21,7 +21,6 @@ from homeassistant.helpers.storage import STORAGE_DIR from .const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, - CONF_LOCK_DEFAULT_CODE, DEFAULT_LOCK_CODE_DIGITS, DOMAIN, LOGGER, @@ -31,7 +30,7 @@ from .const import ( class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Verisure.""" - VERSION = 1 + VERSION = 2 email: str entry: ConfigEntry @@ -306,16 +305,10 @@ class VerisureOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage Verisure options.""" - errors = {} + errors: dict[str, Any] = {} if user_input is not None: - if len(user_input[CONF_LOCK_DEFAULT_CODE]) not in [ - 0, - user_input[CONF_LOCK_CODE_DIGITS], - ]: - errors["base"] = "code_format_mismatch" - else: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", @@ -323,14 +316,12 @@ class VerisureOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_LOCK_CODE_DIGITS, - default=self.entry.options.get( - CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS - ), + description={ + "suggested_value": self.entry.options.get( + CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS + ) + }, ): int, - vol.Optional( - CONF_LOCK_DEFAULT_CODE, - default=self.entry.options.get(CONF_LOCK_DEFAULT_CODE, ""), - ): str, } ), errors=errors, diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index bbfaed0a0a4..f31d36aa2da 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -47,7 +47,7 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): try: await self.hass.async_add_executor_job(self.verisure.login_cookie) except VerisureLoginError as ex: - LOGGER.error("Could not log in to verisure, %s", ex) + LOGGER.error("Credentials expired for Verisure, %s", ex) raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex except VerisureError as ex: LOGGER.error("Could not log in to verisure, %s", ex) @@ -63,8 +63,16 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): """Fetch data from Verisure.""" try: await self.hass.async_add_executor_job(self.verisure.update_cookie) - except VerisureLoginError as ex: - raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureLoginError: + LOGGER.debug("Cookie expired, acquiring new cookies") + try: + await self.hass.async_add_executor_job(self.verisure.login_cookie) + except VerisureLoginError as ex: + LOGGER.error("Credentials expired for Verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureError as ex: + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Could not log in to verisure") from ex except VerisureError as ex: raise UpdateFailed("Unable to update cookie") from ex try: @@ -83,17 +91,15 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): raise UpdateFailed("Could not read overview") from err def unpack(overview: list, value: str) -> dict | list: - return ( - next( - ( - item["data"]["installation"][value] - for item in overview - if value in item.get("data", {}).get("installation", {}) - ), - [], - ) - or [] + unpacked: dict | list | None = next( + ( + item["data"]["installation"][value] + for item in overview + if value in item.get("data", {}).get("installation", {}) + ), + None, ) + return unpacked or [] # Store data in a way Home Assistant can easily consume it self._overview = overview diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 6af64060ab5..1a81b437116 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE, STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -20,7 +20,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, - CONF_LOCK_DEFAULT_CODE, DEFAULT_LOCK_CODE_DIGITS, DOMAIN, LOGGER, @@ -71,9 +70,6 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt self.serial_number = serial_number self._state: str | None = None - self._digits = coordinator.entry.options.get( - CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS - ) @property def device_info(self) -> DeviceInfo: @@ -112,8 +108,11 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def code_format(self) -> str: - """Return the required six digit code.""" - return "^\\d{%s}$" % self._digits + """Return the configured code format.""" + digits = self.coordinator.entry.options.get( + CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS + ) + return "^\\d{%s}$" % digits @property def is_locked(self) -> bool: @@ -129,25 +128,15 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" - code = kwargs.get( - ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) - ) - if code is None: - LOGGER.error("Code required but none provided") - return - - await self.async_set_lock_state(code, STATE_UNLOCKED) + code = kwargs.get(ATTR_CODE) + if code: + await self.async_set_lock_state(code, STATE_UNLOCKED) async def async_lock(self, **kwargs: Any) -> None: """Send lock command.""" - code = kwargs.get( - ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) - ) - if code is None: - LOGGER.error("Code required but none provided") - return - - await self.async_set_lock_state(code, STATE_LOCKED) + code = kwargs.get(ATTR_CODE) + if code: + await self.async_set_lock_state(code, STATE_LOCKED) async def async_set_lock_state(self, code: str, state: str) -> None: """Send set lock state command.""" diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 98440f67e4c..7c9e7057b0c 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==2.6.4"] + "requirements": ["vsure==2.6.6"] } diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index a4f4d1b4e43..0fb16aa87c4 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -9,7 +9,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index f715529b36b..051f17262a0 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -48,13 +48,9 @@ "step": { "init": { "data": { - "lock_code_digits": "Number of digits in PIN code for locks", - "lock_default_code": "Default PIN code for locks, used if none is given" + "lock_code_digits": "Number of digits in PIN code for locks" } } - }, - "error": { - "code_format_mismatch": "The default PIN code does not match the required number of digits" } }, "entity": { diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index 6c3dcd81295..427ca5e6ea8 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index bdebf9f0255..2dcb0028b27 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -75,6 +75,7 @@ BOARD_MAP: Final[dict[str, str]] = { "Generic AArch64": "generic-aarch64", "Generic x86-64": "generic-x86-64", "Home Assistant Yellow": "yellow", + "Home Assistant Green": "green", "Khadas VIM3": "khadas-vim3", } diff --git a/homeassistant/components/version/entity.py b/homeassistant/components/version/entity.py index d950c6394b8..0ac1d834aac 100644 --- a/homeassistant/components/version/entity.py +++ b/homeassistant/components/version/entity.py @@ -1,7 +1,7 @@ """Common entity class for Version integration.""" -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, HOME_ASSISTANT diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 8e6ad545bd0..0e01a593021 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -4,7 +4,8 @@ from typing import Any from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, ToggleEntity from .const import DOMAIN, VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 9326db64d0a..4043cc865c7 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -7,7 +7,6 @@ import logging import time import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -79,7 +78,7 @@ async def async_http_request(hass, uri): """Perform actual request.""" try: session = async_get_clientsession(hass) - async with async_timeout.timeout(REQUEST_TIMEOUT): + async with asyncio.timeout(REQUEST_TIMEOUT): req = await session.get(uri) if req.status != HTTPStatus.OK: return {"error": req.status} diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 385b64e845f..89e8bec42d1 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index c0e7117a74c..ac025ff37d1 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixinWithSet diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 9a55da0f219..d5beff4b268 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 43edf1a0cef..24f23b0da0a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -228,6 +228,14 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, ), + ViCareSensorEntityDescription( + key="gas_summary_consumption_heating_lastsevendays", + name="Heating gas consumption last seven days", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + value_getter=lambda api: api.getGasSummaryConsumptionHeatingLastSevenDays(), + unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), + state_class=SensorStateClass.TOTAL_INCREASING, + ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentday", name="Hot water gas consumption current day", diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 59ed07bdeb2..c0d77dd46b6 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index 7bdba371f49..511e25bbfba 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -38,14 +39,14 @@ class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMix SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( VilfoSensorEntityDescription( key=ATTR_LOAD, - name="Load", + translation_key=ATTR_LOAD, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", api_key=ATTR_API_DATA_FIELD_LOAD, ), VilfoSensorEntityDescription( key=ATTR_BOOT_TIME, - name="Boot time", + translation_key=ATTR_BOOT_TIME, icon="mdi:timer-outline", api_key=ATTR_API_DATA_FIELD_BOOT_TIME, device_class=SensorDeviceClass.TIMESTAMP, @@ -70,18 +71,19 @@ class VilfoRouterSensor(SensorEntity): """Define a Vilfo Router Sensor.""" entity_description: VilfoSensorEntityDescription + _attr_has_entity_name = True def __init__(self, api, description: VilfoSensorEntityDescription) -> None: """Initialize.""" self.entity_description = description self.api = api - self._device_info = { - "identifiers": {(DOMAIN, api.host, api.mac_address)}, - "name": ROUTER_DEFAULT_NAME, - "manufacturer": ROUTER_MANUFACTURER, - "model": ROUTER_DEFAULT_MODEL, - "sw_version": api.firmware_version, - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, api.host, api.mac_address)}, # type: ignore[arg-type] + name=ROUTER_DEFAULT_NAME, + manufacturer=ROUTER_MANUFACTURER, + model=ROUTER_DEFAULT_MODEL, + sw_version=api.firmware_version, + ) self._attr_unique_id = f"{api.unique_id}_{description.key}" @property @@ -89,17 +91,6 @@ class VilfoRouterSensor(SensorEntity): """Return whether the sensor is available or not.""" return self.api.available - @property - def device_info(self): - """Return the device info.""" - return self._device_info - - @property - def name(self): - """Return the name of the sensor.""" - parent_device_name = self._device_info["name"] - return f"{parent_device_name} {self.entity_description.name}" - async def async_update(self) -> None: """Update the router data.""" await self.api.async_update() diff --git a/homeassistant/components/vilfo/strings.json b/homeassistant/components/vilfo/strings.json index 6577c99456c..d559e3a6716 100644 --- a/homeassistant/components/vilfo/strings.json +++ b/homeassistant/components/vilfo/strings.json @@ -16,5 +16,15 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "load": { + "name": "Load" + }, + "boot_time": { + "name": "Boot time" + } + } } } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index a989cea488f..057fd33e8dc 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -26,11 +26,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VizioAppsDataUpdateCoordinator diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 14728c05e53..87bc158331e 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -21,8 +21,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py new file mode 100644 index 00000000000..c1cf23d974f --- /dev/null +++ b/homeassistant/components/vodafone_station/__init__.py @@ -0,0 +1,40 @@ +"""Vodafone Station integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import VodafoneStationRouter + +PLATFORMS = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Vodafone Station platform.""" + coordinator = VodafoneStationRouter( + hass, + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.unique_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.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + await coordinator.api.logout() + await coordinator.api.close() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py new file mode 100644 index 00000000000..e4a087f6903 --- /dev/null +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow for Vodafone Station integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiovodafone import VodafoneStationApi, exceptions as aiovodafone_exceptions +import voluptuous as vol + +from homeassistant import core +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 .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN + + +def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: + """Return user form schema.""" + user_input = user_input or {} + return vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + + api = VodafoneStationApi(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + + try: + await api.login() + finally: + await api.logout() + await api.close() + + return {"title": data[CONF_HOST]} + + +class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Vodafone Station.""" + + VERSION = 1 + entry: ConfigEntry | None = None + + 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=user_form_schema(user_input) + ) + + # Use host because no serial number or mac is available to use for a unique id + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except aiovodafone_exceptions.CannotConnect: + errors["base"] = "cannot_connect" + except aiovodafone_exceptions.CannotAuthenticate: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=user_form_schema(user_input), errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth flow.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert self.entry + self.context["title_placeholders"] = {"host": self.entry.data[CONF_HOST]} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauth confirm.""" + assert self.entry + errors = {} + + if user_input is not None: + try: + await validate_input(self.hass, {**self.entry.data, **user_input}) + except aiovodafone_exceptions.CannotConnect: + errors["base"] = "cannot_connect" + except aiovodafone_exceptions.CannotAuthenticate: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_HOST: self.entry.data[CONF_HOST]}, + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py new file mode 100644 index 00000000000..8d5a60afb60 --- /dev/null +++ b/homeassistant/components/vodafone_station/const.py @@ -0,0 +1,11 @@ +"""Vodafone Station constants.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "vodafone_station" + +DEFAULT_DEVICE_NAME = "Unknown device" +DEFAULT_HOST = "192.168.1.1" +DEFAULT_USERNAME = "vodafone" +DEFAULT_SSL = True diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py new file mode 100644 index 00000000000..b79acac9ce9 --- /dev/null +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -0,0 +1,124 @@ +"""Support for Vodafone Station.""" +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any + +from aiovodafone import VodafoneStationApi, VodafoneStationDevice, exceptions + +from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import _LOGGER, DOMAIN + +CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() + + +@dataclass(slots=True) +class VodafoneStationDeviceInfo: + """Representation of a device connected to the Vodafone Station.""" + + device: VodafoneStationDevice + update_time: datetime | None + home: bool + + +@dataclass(slots=True) +class UpdateCoordinatorDataType: + """Update coordinator data type.""" + + devices: dict[str, VodafoneStationDeviceInfo] + sensors: dict[str, Any] + + +class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): + """Queries router running Vodafone Station firmware.""" + + def __init__( + self, + hass: HomeAssistant, + host: str, + username: str, + password: str, + config_entry_unique_id: str | None, + ) -> None: + """Initialize the scanner.""" + + self._host = host + self.api = VodafoneStationApi(host, username, password) + + # Last resort as no MAC or S/N can be retrieved via API + self._id = config_entry_unique_id + + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{DOMAIN}-{host}-coordinator", + update_interval=timedelta(seconds=30), + ) + + def _calculate_update_time_and_consider_home( + self, device: VodafoneStationDevice, utc_point_in_time: datetime + ) -> tuple[datetime | None, bool]: + """Return update time and consider home. + + If the device is connected, return the current time and True. + + If the device is not connected, return the last update time and + whether the device was considered home at that time. + + If the device is not connected and there is no last update time, + return None and False. + """ + if device.connected: + return utc_point_in_time, True + + if ( + (data := self.data) + and (stored_device := data.devices.get(device.mac)) + and (update_time := stored_device.update_time) + ): + return ( + update_time, + ( + (utc_point_in_time - update_time).total_seconds() + < CONSIDER_HOME_SECONDS + ), + ) + + return None, False + + async def _async_update_data(self) -> UpdateCoordinatorDataType: + """Update router data.""" + _LOGGER.debug("Polling Vodafone Station host: %s", self._host) + try: + logged = await self.api.login() + except exceptions.CannotConnect as err: + _LOGGER.warning("Connection error for %s", self._host) + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err + + if not logged: + raise ConfigEntryAuthFailed + + utc_point_in_time = dt_util.utcnow() + data_devices = { + dev_info.mac: VodafoneStationDeviceInfo( + dev_info, + *self._calculate_update_time_and_consider_home( + dev_info, utc_point_in_time + ), + ) + for dev_info in (await self.api.get_all_devices()).values() + } + data_sensors = await self.api.get_user_data() + await self.api.logout() + return UpdateCoordinatorDataType(data_devices, data_sensors) + + @property + def signal_device_new(self) -> str: + """Event specific per Vodafone Station entry to signal new device.""" + return f"{DOMAIN}-device-new-{self._id}" diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py new file mode 100644 index 00000000000..9f98da88d22 --- /dev/null +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -0,0 +1,114 @@ +"""Support for Vodafone Station routers.""" +from __future__ import annotations + +from aiovodafone import VodafoneStationDevice + +from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import _LOGGER, DOMAIN +from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up device tracker for Vodafone Station component.""" + + _LOGGER.debug("Start device trackers setup") + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + tracked: set = set() + + @callback + def async_update_router() -> None: + """Update the values of the router.""" + async_add_new_tracked_entities(coordinator, async_add_entities, tracked) + + entry.async_on_unload( + async_dispatcher_connect( + hass, coordinator.signal_device_new, async_update_router + ) + ) + + async_update_router() + + +@callback +def async_add_new_tracked_entities( + coordinator: VodafoneStationRouter, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: + """Add new tracker entities from the router.""" + new_tracked = [] + + _LOGGER.debug("Adding device trackers entities") + for mac, device_info in coordinator.data.devices.items(): + if mac in tracked: + continue + _LOGGER.debug("New device tracker: %s", device_info.device.name) + new_tracked.append(VodafoneStationTracker(coordinator, device_info)) + tracked.add(mac) + + async_add_entities(new_tracked) + + +class VodafoneStationTracker(CoordinatorEntity[VodafoneStationRouter], ScannerEntity): + """Representation of a Vodafone Station device.""" + + def __init__( + self, coordinator: VodafoneStationRouter, device_info: VodafoneStationDeviceInfo + ) -> None: + """Initialize a Vodafone Station device.""" + super().__init__(coordinator) + self._coordinator = coordinator + device = device_info.device + mac = device.mac + self._device_mac = mac + self._attr_unique_id = mac + self._attr_name = device.name or mac.replace(":", "_") + + @property + def _device_info(self) -> VodafoneStationDeviceInfo: + """Return fresh data for the device.""" + return self.coordinator.data.devices[self._device_mac] + + @property + def _device(self) -> VodafoneStationDevice: + """Return fresh data for the device.""" + return self.coordinator.data.devices[self._device_mac].device + + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return self._device_info.home + + @property + def source_type(self) -> SourceType: + """Return the source type.""" + return SourceType.ROUTER + + @property + def hostname(self) -> str | None: + """Return the hostname of device.""" + return self._attr_name + + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:lan-connect" if self._device.connected else "mdi:lan-disconnect" + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + return self._device.ip_address + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._device_mac diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json new file mode 100644 index 00000000000..7069629ca2e --- /dev/null +++ b/homeassistant/components/vodafone_station/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "vodafone_station", + "name": "Vodafone Station", + "codeowners": ["@paoloantinori", "@chemelli74"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/vodafone_station", + "iot_class": "local_polling", + "loggers": ["aiovodafone"], + "requirements": ["aiovodafone==0.0.6"] +} diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json new file mode 100644 index 00000000000..3c452133c28 --- /dev/null +++ b/homeassistant/components/vodafone_station/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "{host}", + "step": { + "reauth_confirm": { + "description": "Please enter the correct password for host: {host}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "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%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 072e0ee431d..5bdc8bee3ac 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -4,7 +4,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -196,7 +195,7 @@ class VoiceRSSProvider(Provider): form_data["hl"] = language try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): request = await websession.post(VOICERSS_API_URL, data=form_data) if request.status != HTTPStatus.OK: diff --git a/homeassistant/components/voip/entity.py b/homeassistant/components/voip/entity.py index 9b3cc641a66..9e1e067b195 100644 --- a/homeassistant/components/voip/entity.py +++ b/homeassistant/components/voip/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN from .devices import VoIPDevice @@ -18,6 +19,6 @@ class VoIPEntity(entity.Entity): """Initialize VoIP entity.""" self._device = device self._attr_unique_id = f"{device.voip_id}-{self.entity_description.key}" - self._attr_device_info = entity.DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.voip_id)}, ) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 3d0681a8475..efa62e0e8f4 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -10,7 +10,6 @@ from pathlib import Path import time from typing import TYPE_CHECKING -import async_timeout from voip_utils import ( CallInfo, RtcpState, @@ -37,13 +36,7 @@ from homeassistant.const import __version__ from homeassistant.core import Context, HomeAssistant from homeassistant.util.ulid import ulid -from .const import ( - CHANNELS, - DOMAIN, - RATE, - RTP_AUDIO_SETTINGS, - WIDTH, -) +from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH if TYPE_CHECKING: from .devices import VoIPDevice, VoIPDevices @@ -265,7 +258,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self._clear_audio_queue() # Run pipeline with a timeout - async with async_timeout.timeout(self.pipeline_timeout): + async with asyncio.timeout(self.pipeline_timeout): await async_pipeline_from_audio_stream( self.hass, context=self._context, @@ -321,7 +314,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): """ # Timeout if no audio comes in for a while. # This means the caller hung up. - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() while chunk: @@ -332,7 +325,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): # Buffer until command starts return True - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() return False @@ -349,7 +342,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): # Timeout if no audio comes in for a while. # This means the caller hung up. - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() while chunk: @@ -359,7 +352,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): yield chunk - async with async_timeout.timeout(self.audio_timeout): + async with asyncio.timeout(self.audio_timeout): chunk = await self._audio_queue.get() def _clear_audio_queue(self) -> None: @@ -401,7 +394,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): tts_samples = len(audio_bytes) / (WIDTH * CHANNELS) tts_seconds = tts_samples / RATE - async with async_timeout.timeout(tts_seconds + self.tts_extra_timeout): + async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): # Assume TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) except asyncio.TimeoutError as err: diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 880d02cfeae..d207e36e3c9 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index ab4fa781110..4ec1bf4a4ba 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -1,9 +1,9 @@ """Support for Volvo On Call.""" +import asyncio import logging from aiohttp.client_exceptions import ClientResponseError -import async_timeout from volvooncall import Connection from volvooncall.dashboard import Instrument @@ -17,8 +17,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -70,12 +70,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: volvo_data = VolvoData(hass, connection, entry) - coordinator = hass.data[DOMAIN][entry.entry_id] = VolvoUpdateCoordinator( - hass, volvo_data - ) + coordinator = VolvoUpdateCoordinator(hass, volvo_data) await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -186,7 +186,7 @@ class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await self.volvo_data.update() diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index 791ae9ee7c4..20c8ff78432 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -16,8 +16,8 @@ from homeassistant.components.calendar import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, generate_entity_id +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN diff --git a/homeassistant/components/w800rf32/binary_sensor.py b/homeassistant/components/w800rf32/binary_sensor.py index 6d1ce5c61c0..2bc0c0eea75 100644 --- a/homeassistant/components/w800rf32/binary_sensor.py +++ b/homeassistant/components/w800rf32/binary_sensor.py @@ -17,7 +17,6 @@ from homeassistant.helpers import config_validation as cv, event as evt from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt as dt_util from . import W800RF32_DEVICE @@ -127,8 +126,8 @@ class W800rf32BinarySensor(BinarySensorEntity): self.update_state(is_on) if self.is_on and self._off_delay is not None and self._delay_listener is None: - self._delay_listener = evt.async_track_point_in_time( - self.hass, self._off_delay_listener, dt_util.utcnow() + self._off_delay + self._delay_listener = evt.async_call_later( + self.hass, self._off_delay, self._off_delay_listener ) def update_state(self, state): diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py new file mode 100644 index 00000000000..b308cf98912 --- /dev/null +++ b/homeassistant/components/wake_word/__init__.py @@ -0,0 +1,122 @@ +"""Provide functionality to wake word.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import AsyncIterable +import logging +from typing import final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .models import DetectionResult, WakeWord + +__all__ = [ + "async_default_engine", + "async_get_wake_word_detection_entity", + "DetectionResult", + "DOMAIN", + "WakeWord", + "WakeWordDetectionEntity", +] + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +@callback +def async_default_engine(hass: HomeAssistant) -> str | None: + """Return the domain or entity id of the default engine.""" + return next(iter(hass.states.async_entity_ids(DOMAIN)), None) + + +@callback +def async_get_wake_word_detection_entity( + hass: HomeAssistant, entity_id: str +) -> WakeWordDetectionEntity | None: + """Return wake word entity.""" + component: EntityComponent[WakeWordDetectionEntity] = hass.data[DOMAIN] + + return component.get_entity(entity_id) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up STT.""" + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) + component.register_shutdown() + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = 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 = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +class WakeWordDetectionEntity(RestoreEntity): + """Represent a single wake word provider.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_should_poll = False + __last_detected: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + return self.__last_detected + + @property + @abstractmethod + def supported_wake_words(self) -> list[WakeWord]: + """Return a list of supported wake words.""" + + @abstractmethod + async def _async_process_audio_stream( + self, stream: AsyncIterable[tuple[bytes, int]] + ) -> DetectionResult | None: + """Try to detect wake word(s) in an audio stream with timestamps. + + Audio must be 16Khz sample rate with 16-bit mono PCM samples. + """ + + async def async_process_audio_stream( + self, stream: AsyncIterable[tuple[bytes, int]] + ) -> DetectionResult | None: + """Try to detect wake word(s) in an audio stream with timestamps. + + Audio must be 16Khz sample rate with 16-bit mono PCM samples. + """ + result = await self._async_process_audio_stream(stream) + if result is not None: + # Update last detected only when there is a detection + self.__last_detected = dt_util.utcnow().isoformat() + self.async_write_ha_state() + + return result + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity 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 + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_detected = state.state diff --git a/homeassistant/components/wake_word/const.py b/homeassistant/components/wake_word/const.py new file mode 100644 index 00000000000..fdca6cfab6e --- /dev/null +++ b/homeassistant/components/wake_word/const.py @@ -0,0 +1,2 @@ +"""Wake word constants.""" +DOMAIN = "wake_word" diff --git a/homeassistant/components/wake_word/manifest.json b/homeassistant/components/wake_word/manifest.json new file mode 100644 index 00000000000..7834fad665c --- /dev/null +++ b/homeassistant/components/wake_word/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "wake_word", + "name": "Wake-word detection", + "codeowners": ["@home-assistant/core", "@synesthesiam"], + "documentation": "https://www.home-assistant.io/integrations/wake_word", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/wake_word/models.py b/homeassistant/components/wake_word/models.py new file mode 100644 index 00000000000..1ea154f1393 --- /dev/null +++ b/homeassistant/components/wake_word/models.py @@ -0,0 +1,24 @@ +"""Wake word models.""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class WakeWord: + """Wake word model.""" + + ww_id: str + name: str + + +@dataclass +class DetectionResult: + """Result of wake word detection.""" + + ww_id: str + """Id of detected wake word""" + + timestamp: int | None + """Timestamp of audio chunk with detected wake word""" + + queued_audio: list[tuple[bytes, int]] | None = None + """Audio chunks that were queued when wake word was detected.""" diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index b5e935c27f1..9b27b9c4bd1 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 3d046d5d241..7a0736f59e7 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -54,11 +54,16 @@ class WallboxSwitch(WallboxEntity, SwitchEntity): @property def available(self) -> bool: """Return the availability of the switch.""" - return self.coordinator.data[CHARGER_STATUS_DESCRIPTION_KEY] in { - ChargerStatus.CHARGING, - ChargerStatus.DISCHARGING, - ChargerStatus.PAUSED, - ChargerStatus.SCHEDULED, + return super().available and self.coordinator.data[ + CHARGER_STATUS_DESCRIPTION_KEY + ] not in { + ChargerStatus.UNKNOWN, + ChargerStatus.UPDATING, + ChargerStatus.ERROR, + ChargerStatus.LOCKED, + ChargerStatus.LOCKED_CAR_CONNECTED, + ChargerStatus.DISCONNECTED, + ChargerStatus.READY, } @property diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index e5630d5fd29..2022558a500 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["waqiasync"], - "requirements": ["waqiasync==1.1.0"] + "requirements": ["aiowaqi==0.2.1"] } diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 71ec703df3f..51b9acb8e59 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,14 +1,11 @@ """Support for the World Air Quality Index service.""" from __future__ import annotations -import asyncio -from contextlib import suppress from datetime import timedelta import logging -import aiohttp +from aiowaqi import WAQIAirQuality, WAQIClient, WAQIConnectionError, WAQISearchResult import voluptuous as vol -from waqiasync import WaqiClient from homeassistant.components.sensor import ( SensorDeviceClass, @@ -39,17 +36,6 @@ ATTR_PM2_5 = "pm_2_5" ATTR_PRESSURE = "pressure" ATTR_SULFUR_DIOXIDE = "sulfur_dioxide" -KEY_TO_ATTR = { - "pm25": ATTR_PM2_5, - "pm10": ATTR_PM10, - "h": ATTR_HUMIDITY, - "p": ATTR_PRESSURE, - "t": ATTR_TEMPERATURE, - "o3": ATTR_OZONE, - "no2": ATTR_NITROGEN_DIOXIDE, - "so2": ATTR_SULFUR_DIOXIDE, -} - ATTRIBUTION = "Data provided by the World Air Quality Index project" ATTR_ICON = "mdi:cloud" @@ -82,7 +68,8 @@ async def async_setup_platform( station_filter = config.get(CONF_STATIONS) locations = config[CONF_LOCATIONS] - client = WaqiClient(token, async_get_clientsession(hass), timeout=TIMEOUT) + client = WAQIClient(session=async_get_clientsession(hass), request_timeout=TIMEOUT) + client.authenticate(token) dev = [] try: for location_name in locations: @@ -96,10 +83,7 @@ async def async_setup_platform( waqi_sensor.station_name, } & set(station_filter): dev.append(waqi_sensor) - except ( - aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError, - ) as err: + except WAQIConnectionError as err: _LOGGER.exception("Failed to connect to WAQI servers") raise PlatformNotReady from err async_add_entities(dev, True) @@ -112,25 +96,14 @@ class WaqiSensor(SensorEntity): _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT - def __init__(self, client, station): + _data: WAQIAirQuality | None = None + + def __init__(self, client: WAQIClient, search_result: WAQISearchResult) -> None: """Initialize the sensor.""" self._client = client - try: - self.uid = station["uid"] - except (KeyError, TypeError): - self.uid = None - - try: - self.url = station["station"]["url"] - except (KeyError, TypeError): - self.url = None - - try: - self.station_name = station["station"]["name"] - except (KeyError, TypeError): - self.station_name = None - - self._data = None + self.uid = search_result.station_id + self.url = search_result.station.external_url + self.station_name = search_result.station.name @property def name(self): @@ -140,12 +113,10 @@ class WaqiSensor(SensorEntity): return f"WAQI {self.url if self.url else self.uid}" @property - def native_value(self): + def native_value(self) -> int | None: """Return the state of the device.""" - if (value := self._data.get("aqi")) is not None: - with suppress(ValueError): - return float(value) - return None + assert self._data + return self._data.air_quality_index @property def available(self): @@ -166,28 +137,35 @@ class WaqiSensor(SensorEntity): try: attrs[ATTR_ATTRIBUTION] = " and ".join( [ATTRIBUTION] - + [v["name"] for v in self._data.get("attributions", [])] + + [attribution.name for attribution in self._data.attributions] ) - attrs[ATTR_TIME] = self._data["time"]["s"] - attrs[ATTR_DOMINENTPOL] = self._data.get("dominentpol") + attrs[ATTR_TIME] = self._data.measured_at + attrs[ATTR_DOMINENTPOL] = self._data.dominant_pollutant - iaqi = self._data["iaqi"] - for key in iaqi: - if key in KEY_TO_ATTR: - attrs[KEY_TO_ATTR[key]] = iaqi[key]["v"] - else: - attrs[key] = iaqi[key]["v"] - return attrs + iaqi = self._data.extended_air_quality + + attribute = { + ATTR_PM2_5: iaqi.pm25, + ATTR_PM10: iaqi.pm10, + ATTR_HUMIDITY: iaqi.humidity, + ATTR_PRESSURE: iaqi.pressure, + ATTR_TEMPERATURE: iaqi.temperature, + ATTR_OZONE: iaqi.ozone, + ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, + ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, + } + res_attributes = {k: v for k, v in attribute.items() if v is not None} + return {**attrs, **res_attributes} except (IndexError, KeyError): return {ATTR_ATTRIBUTION: ATTRIBUTION} async def async_update(self) -> None: """Get the latest data and updates the states.""" if self.uid: - result = await self._client.get_station_by_number(self.uid) + result = await self._client.get_by_station_number(self.uid) elif self.url: - result = await self._client.get_station_by_name(self.url) + result = await self._client.get_by_name(self.url) else: result = None self._data = result diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 5ddb61d28b0..1b3af02610c 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -16,6 +16,15 @@ "high_demand": "High Demand", "heat_pump": "Heat Pump", "performance": "Performance" + }, + "state_attributes": { + "away_mode": { + "name": "Away mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } } } }, diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 7af6c1ce97b..7adb1b1582f 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -1,10 +1,8 @@ """Support for IBM Watson TTS integration.""" import logging -from ibm_cloud_sdk_core.authenticators import ( # pylint: disable=import-error - IAMAuthenticator, -) -from ibm_watson import TextToSpeechV1 # pylint: disable=import-error +from ibm_cloud_sdk_core.authenticators import IAMAuthenticator +from ibm_watson import TextToSpeechV1 import voluptuous as vol from homeassistant.components.tts import PLATFORM_SCHEMA, Provider diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index c8e9a376fdc..2a0e21ecf4c 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, PERCENTAGE, UnitOfMass 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 ( @@ -35,14 +36,14 @@ SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT = "percent" REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=SENSOR_TYPE_REALTIME_EMISSIONS_MOER, - name="Marginal operating emissions rate", + translation_key="marginal_operating_emissions_rate", icon="mdi:blur", native_unit_of_measurement=f"{UnitOfMass.POUNDS} CO2/MWh", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_REALTIME_EMISSIONS_PERCENT, - name="Relative marginal emissions intensity", + translation_key="relative_marginal_emissions_intensity", icon="mdi:blur", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -77,13 +78,14 @@ class RealtimeEmissionsSensor(CoordinatorEntity, SensorEntity): ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - - self._attr_name = ( - f"{description.name} ({entry.data[CONF_BALANCING_AUTHORITY_ABBREV]})" - ) self._attr_unique_id = f"{entry.entry_id}_{description.key}" self._entry = entry self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.data[CONF_BALANCING_AUTHORITY_ABBREV], + entry_type=DeviceEntryType.SERVICE, + ) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: diff --git a/homeassistant/components/watttime/strings.json b/homeassistant/components/watttime/strings.json index 1650856a669..b62a9961131 100644 --- a/homeassistant/components/watttime/strings.json +++ b/homeassistant/components/watttime/strings.json @@ -48,5 +48,15 @@ } } } + }, + "entity": { + "sensor": { + "marginal_operating_emissions_rate": { + "name": "Marginal operating emissions rate" + }, + "relative_marginal_emissions_intensity": { + "name": "Relative marginal emissions intensity" + } + } } } diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index a743844659c..60134452025 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -129,8 +129,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input: user_input[CONF_REGION] = user_input[CONF_REGION].upper() - if await self.hass.async_add_executor_job( - is_valid_config_entry, + if await is_valid_config_entry( self.hass, user_input[CONF_ORIGIN], user_input[CONF_DESTINATION], diff --git a/homeassistant/components/waze_travel_time/helpers.py b/homeassistant/components/waze_travel_time/helpers.py index 8468bb8ea9a..0659424429f 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -1,19 +1,25 @@ """Helpers for Waze Travel Time integration.""" import logging -from WazeRouteCalculator import WazeRouteCalculator, WRCError +from pywaze.route_calculator import WazeRouteCalculator, WRCError +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates _LOGGER = logging.getLogger(__name__) -def is_valid_config_entry(hass, origin, destination, region): +async def is_valid_config_entry( + hass: HomeAssistant, origin: str, destination: str, region: str +) -> bool: """Return whether the config entry data is valid.""" - origin = find_coordinates(hass, origin) - destination = find_coordinates(hass, destination) + resolved_origin = find_coordinates(hass, origin) + resolved_destination = find_coordinates(hass, destination) + httpx_client = get_async_client(hass) + client = WazeRouteCalculator(region=region, client=httpx_client) try: - WazeRouteCalculator(origin, destination, region).calc_all_routes_info() + await client.calc_all_routes_info(resolved_origin, resolved_destination) except WRCError as error: _LOGGER.error("Error trying to validate entry: %s", error) return False diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json index 5e19ee6949c..3f1f8c6d67b 100644 --- a/homeassistant/components/waze_travel_time/manifest.json +++ b/homeassistant/components/waze_travel_time/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waze_travel_time", "iot_class": "cloud_polling", - "loggers": ["WazeRouteCalculator", "homeassistant.helpers.location"], - "requirements": ["WazeRouteCalculator==0.14"] + "loggers": ["pywaze", "homeassistant.helpers.location"], + "requirements": ["pywaze==0.3.0"] } diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index cf709805f6d..2b3010a39cb 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -5,7 +5,8 @@ from datetime import timedelta import logging from typing import Any -from WazeRouteCalculator import WazeRouteCalculator, WRCError +import httpx +from pywaze.route_calculator import WazeRouteCalculator, WRCError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -21,9 +22,9 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates from homeassistant.util.unit_conversion import DistanceConverter @@ -61,6 +62,7 @@ async def async_setup_entry( data = WazeTravelTimeData( region, + get_async_client(hass), config_entry, ) @@ -133,31 +135,33 @@ class WazeTravelTime(SensorEntity): async def first_update(self, _=None) -> None: """Run first update and write state.""" - await self.hass.async_add_executor_job(self.update) + await self.async_update() self.async_write_ha_state() - def update(self) -> None: + async def async_update(self) -> None: """Fetch new state data for the sensor.""" _LOGGER.debug("Fetching Route for %s", self._attr_name) self._waze_data.origin = find_coordinates(self.hass, self._origin) self._waze_data.destination = find_coordinates(self.hass, self._destination) - self._waze_data.update() + await self._waze_data.async_update() class WazeTravelTimeData: """WazeTravelTime Data object.""" - def __init__(self, region: str, config_entry: ConfigEntry) -> None: + def __init__( + self, region: str, client: httpx.AsyncClient, config_entry: ConfigEntry + ) -> None: """Set up WazeRouteCalculator.""" - self.region = region self.config_entry = config_entry + self.client = WazeRouteCalculator(region=region, client=client) self.origin: str | None = None self.destination: str | None = None self.duration = None self.distance = None self.route = None - def update(self): + async def async_update(self): """Update WazeRouteCalculator Sensor.""" _LOGGER.debug( "Getting update for origin: %s destination: %s", @@ -178,17 +182,17 @@ class WazeTravelTimeData: avoid_ferries = self.config_entry.options[CONF_AVOID_FERRIES] units = self.config_entry.options[CONF_UNITS] + routes = {} try: - params = WazeRouteCalculator( + routes = await self.client.calc_all_routes_info( self.origin, self.destination, - self.region, - vehicle_type, - avoid_toll_roads, - avoid_subscription_roads, - avoid_ferries, + vehicle_type=vehicle_type, + avoid_toll_roads=avoid_toll_roads, + avoid_subscription_roads=avoid_subscription_roads, + avoid_ferries=avoid_ferries, + real_time=realtime, ) - routes = params.calc_all_routes_info(real_time=realtime) if incl_filter not in {None, ""}: routes = { diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index f0c32f2d8cc..0d72dbb825e 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,13 +1,28 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations +import abc +import asyncio from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta +from functools import partial import inspect import logging -from typing import Any, Final, Literal, Required, TypedDict, final +from typing import ( + Any, + Final, + Generic, + Literal, + Required, + TypedDict, + TypeVar, + cast, + final, +) + +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -18,14 +33,31 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import EntityPlatform +import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + TimestampDataUpdateCoordinator, +) +from homeassistant.util.dt import utcnow +from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( # noqa: F401 @@ -103,6 +135,26 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 +SERVICE_GET_FORECAST: Final = "get_forecast" + +_ObservationUpdateCoordinatorT = TypeVar( + "_ObservationUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" +) + +# Note: +# Mypy bug https://github.com/python/mypy/issues/9424 prevents us from making the +# forecast cooordinators optional, bound=TimestampDataUpdateCoordinator[Any] | None + +_DailyForecastUpdateCoordinatorT = TypeVar( + "_DailyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" +) +_HourlyForecastUpdateCoordinatorT = TypeVar( + "_HourlyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" +) +_TwiceDailyForecastUpdateCoordinatorT = TypeVar( + "_TwiceDailyForecastUpdateCoordinatorT", bound="TimestampDataUpdateCoordinator[Any]" +) + # mypy: disallow-any-generics @@ -158,6 +210,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) + component.async_register_entity_service( + SERVICE_GET_FORECAST, + {vol.Required("type"): vol.In(("daily", "hourly", "twice_daily"))}, + async_get_forecast_service, + required_features=[ + WeatherEntityFeature.FORECAST_DAILY, + WeatherEntityFeature.FORECAST_HOURLY, + WeatherEntityFeature.FORECAST_TWICE_DAILY, + ], + supports_response=SupportsResponse.ONLY, + ) async_setup_ws_api(hass) await component.async_setup(config) return True @@ -180,11 +243,29 @@ class WeatherEntityDescription(EntityDescription): """A class that describes weather entities.""" -class WeatherEntity(Entity): +class PostInitMeta(abc.ABCMeta): + """Meta class which calls __post_init__ after __new__ and __init__.""" + + def __call__(cls, *args: Any, **kwargs: Any) -> Any: + """Create an instance.""" + instance: PostInit = super().__call__(*args, **kwargs) + instance.__post_init__(*args, **kwargs) + return instance + + +class PostInit(metaclass=PostInitMeta): + """Class which calls __post_init__ after __new__ and __init__.""" + + @abc.abstractmethod + def __post_init__(self, *args: Any, **kwargs: Any) -> None: + """Finish initializing.""" + + +class WeatherEntity(Entity, PostInit): """ABC for weather data.""" entity_description: WeatherEntityDescription - _attr_condition: str | None + _attr_condition: str | None = None # _attr_forecast is deprecated, implement async_forecast_daily, # async_forecast_hourly or async_forecast_twice daily instead _attr_forecast: list[Forecast] | None = None @@ -193,35 +274,8 @@ class WeatherEntity(Entity): _attr_cloud_coverage: int | None = None _attr_uv_index: float | None = None _attr_precision: float - _attr_pressure: None = ( - None # Provide backwards compatibility. Use _attr_native_pressure - ) - _attr_pressure_unit: None = ( - None # Provide backwards compatibility. Use _attr_native_pressure_unit - ) _attr_state: None = None - _attr_temperature: None = ( - None # Provide backwards compatibility. Use _attr_native_temperature - ) - _attr_temperature_unit: None = ( - None # Provide backwards compatibility. Use _attr_native_temperature_unit - ) - _attr_visibility: None = ( - None # Provide backwards compatibility. Use _attr_native_visibility - ) - _attr_visibility_unit: None = ( - None # Provide backwards compatibility. Use _attr_native_visibility_unit - ) - _attr_precipitation_unit: None = ( - None # Provide backwards compatibility. Use _attr_native_precipitation_unit - ) _attr_wind_bearing: float | str | None = None - _attr_wind_speed: None = ( - None # Provide backwards compatibility. Use _attr_native_wind_speed - ) - _attr_wind_speed_unit: None = ( - None # Provide backwards compatibility. Use _attr_native_wind_speed_unit - ) _attr_native_pressure: float | None = None _attr_native_pressure_unit: str | None = None @@ -238,8 +292,9 @@ class WeatherEntity(Entity): _forecast_listeners: dict[ Literal["daily", "hourly", "twice_daily"], - list[Callable[[list[dict[str, Any]] | None], None]], + list[Callable[[list[JsonValueType] | None], None]], ] + __weather_legacy_forecast: bool = False _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None @@ -247,62 +302,70 @@ class WeatherEntity(Entity): _weather_option_precipitation_unit: str | None = None _weather_option_wind_speed_unit: str | None = None + def __post_init__(self, *args: Any, **kwargs: Any) -> None: + """Finish initializing.""" + self._forecast_listeners = {"daily": [], "hourly": [], "twice_daily": []} + def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" super().__init_subclass__(**kwargs) - _reported = False if any( + method in cls.__dict__ for method in ("_attr_forecast", "forecast") + ) and not any( method in cls.__dict__ for method in ( - "_attr_temperature", - "temperature", - "_attr_temperature_unit", - "temperature_unit", - "_attr_pressure", - "pressure", - "_attr_pressure_unit", - "pressure_unit", - "_attr_wind_speed", - "wind_speed", - "_attr_wind_speed_unit", - "wind_speed_unit", - "_attr_visibility", - "visibility", - "_attr_visibility_unit", - "visibility_unit", - "_attr_precipitation_unit", - "precipitation_unit", + "async_forecast_daily", + "async_forecast_hourly", + "async_forecast_twice_daily", ) ): - if _reported is False: - module = inspect.getmodule(cls) - _reported = True - if ( - module - and module.__file__ - and "custom_components" in module.__file__ - ): - report_issue = "report it to the custom integration author." - else: - report_issue = ( - "create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) + cls.__weather_legacy_forecast = True + + @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) + _reported_forecast = False + if self.__weather_legacy_forecast and not _reported_forecast: + module = inspect.getmodule(self) + if module and module.__file__ and "custom_components" in module.__file__: + # Do not report on core integrations as they are already fixed or PR is open. + report_issue = "report it to the custom integration author." _LOGGER.warning( ( - "%s::%s is overriding deprecated methods on an instance of " - "WeatherEntity, this is not valid and will be unsupported " - "from Home Assistant 2023.1. Please %s" + "%s::%s is using a forecast attribute on an instance of " + "WeatherEntity, this is deprecated and will be unsupported " + "from Home Assistant 2024.3. Please %s" ), - cls.__module__, - cls.__name__, + self.__module__, + self.entity_id, report_issue, ) + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_weather_forecast_{self.platform.platform_name}", + breaks_in_ha_version="2024.3.0", + is_fixable=False, + is_persistent=False, + issue_domain=self.platform.platform_name, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_weather_forecast", + translation_placeholders={ + "platform": self.platform.platform_name, + "report_issue": report_issue, + }, + ) + _reported_forecast = True async def async_internal_added_to_hass(self) -> None: """Call when the weather entity is added to hass.""" await super().async_internal_added_to_hass() - self._forecast_listeners = {"daily": [], "hourly": [], "twice_daily": []} if not self.registry_entry: return self.async_registry_entry_updated() @@ -312,29 +375,14 @@ class WeatherEntity(Entity): """Return the apparent temperature in native units.""" return self._attr_native_temperature - @final - @property - def temperature(self) -> float | None: - """Return the temperature for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_temperature - @property def native_temperature(self) -> float | None: """Return the temperature in native units.""" - if (temperature := self.temperature) is not None: - return temperature - return self._attr_native_temperature @property def native_temperature_unit(self) -> str | None: """Return the native unit of measurement for temperature.""" - if (temperature_unit := self.temperature_unit) is not None: - return temperature_unit - return self._attr_native_temperature_unit @property @@ -342,15 +390,6 @@ class WeatherEntity(Entity): """Return the dew point temperature in native units.""" return self._attr_native_dew_point - @final - @property - def temperature_unit(self) -> str | None: - """Return the temperature unit for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_temperature_unit - @final @property def _default_temperature_unit(self) -> str: @@ -374,40 +413,16 @@ class WeatherEntity(Entity): return self._default_temperature_unit - @final - @property - def pressure(self) -> float | None: - """Return the pressure for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_pressure - @property def native_pressure(self) -> float | None: """Return the pressure in native units.""" - if (pressure := self.pressure) is not None: - return pressure - return self._attr_native_pressure @property def native_pressure_unit(self) -> str | None: """Return the native unit of measurement for pressure.""" - if (pressure_unit := self.pressure_unit) is not None: - return pressure_unit - return self._attr_native_pressure_unit - @final - @property - def pressure_unit(self) -> str | None: - """Return the pressure unit for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_pressure_unit - @final @property def _default_pressure_unit(self) -> str: @@ -443,40 +458,16 @@ class WeatherEntity(Entity): """Return the wind gust speed in native units.""" return self._attr_native_wind_gust_speed - @final - @property - def wind_speed(self) -> float | None: - """Return the wind_speed for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_wind_speed - @property def native_wind_speed(self) -> float | None: """Return the wind speed in native units.""" - if (wind_speed := self.wind_speed) is not None: - return wind_speed - return self._attr_native_wind_speed @property def native_wind_speed_unit(self) -> str | None: """Return the native unit of measurement for wind speed.""" - if (wind_speed_unit := self.wind_speed_unit) is not None: - return wind_speed_unit - return self._attr_native_wind_speed_unit - @final - @property - def wind_speed_unit(self) -> str | None: - """Return the wind_speed unit for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_wind_speed_unit - @final @property def _default_wind_speed_unit(self) -> str: @@ -522,40 +513,16 @@ class WeatherEntity(Entity): """Return the UV index.""" return self._attr_uv_index - @final - @property - def visibility(self) -> float | None: - """Return the visibility for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_visibility - @property def native_visibility(self) -> float | None: """Return the visibility in native units.""" - if (visibility := self.visibility) is not None: - return visibility - return self._attr_native_visibility @property def native_visibility_unit(self) -> str | None: """Return the native unit of measurement for visibility.""" - if (visibility_unit := self.visibility_unit) is not None: - return visibility_unit - return self._attr_native_visibility_unit - @final - @property - def visibility_unit(self) -> str | None: - """Return the visibility unit for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_visibility_unit - @final @property def _default_visibility_unit(self) -> str: @@ -602,20 +569,8 @@ class WeatherEntity(Entity): @property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" - if (precipitation_unit := self.precipitation_unit) is not None: - return precipitation_unit - return self._attr_native_precipitation_unit - @final - @property - def precipitation_unit(self) -> str | None: - """Return the precipitation unit for backward compatibility. - - Should not be set by integrations. - """ - return self._attr_precipitation_unit - @final @property def _default_precipitation_unit(self) -> str: @@ -789,9 +744,9 @@ class WeatherEntity(Entity): @final def _convert_forecast( self, native_forecast_list: list[Forecast] - ) -> list[dict[str, Any]]: + ) -> list[JsonValueType]: """Convert a forecast in native units to the unit configured by the user.""" - converted_forecast_list: list[dict[str, Any]] = [] + converted_forecast_list: list[JsonValueType] = [] precision = self.precision from_temp_unit = self.native_temperature_unit or self._default_temperature_unit @@ -1024,22 +979,43 @@ class WeatherEntity(Entity): ) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]: self._weather_option_visibility_unit = custom_unit_visibility + @callback + def _async_subscription_started( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """Start subscription to forecast_type.""" + return None + + @callback + def _async_subscription_ended( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """End subscription to forecast_type.""" + return None + @final @callback def async_subscribe_forecast( self, forecast_type: Literal["daily", "hourly", "twice_daily"], - forecast_listener: Callable[[list[dict[str, Any]] | None], None], + forecast_listener: Callable[[list[JsonValueType] | None], None], ) -> CALLBACK_TYPE: """Subscribe to forecast updates. Called by websocket API. """ + subscription_started = not self._forecast_listeners[forecast_type] self._forecast_listeners[forecast_type].append(forecast_listener) + if subscription_started: + self._async_subscription_started(forecast_type) @callback def unsubscribe() -> None: self._forecast_listeners[forecast_type].remove(forecast_listener) + if not self._forecast_listeners[forecast_type]: + self._async_subscription_ended(forecast_type) return unsubscribe @@ -1073,3 +1049,244 @@ class WeatherEntity(Entity): converted_forecast_list = self._convert_forecast(native_forecast_list) for listener in self._forecast_listeners[forecast_type]: listener(converted_forecast_list) + + +def raise_unsupported_forecast(entity_id: str, forecast_type: str) -> None: + """Raise error on attempt to get an unsupported forecast.""" + raise HomeAssistantError( + f"Weather entity '{entity_id}' does not support '{forecast_type}' forecast" + ) + + +async def async_get_forecast_service( + weather: WeatherEntity, service_call: ServiceCall +) -> ServiceResponse: + """Get weather forecast.""" + forecast_type = service_call.data["type"] + supported_features = weather.supported_features or 0 + if forecast_type == "daily": + if (supported_features & WeatherEntityFeature.FORECAST_DAILY) == 0: + raise_unsupported_forecast(weather.entity_id, forecast_type) + native_forecast_list = await weather.async_forecast_daily() + elif forecast_type == "hourly": + if (supported_features & WeatherEntityFeature.FORECAST_HOURLY) == 0: + raise_unsupported_forecast(weather.entity_id, forecast_type) + native_forecast_list = await weather.async_forecast_hourly() + else: + if (supported_features & WeatherEntityFeature.FORECAST_TWICE_DAILY) == 0: + raise_unsupported_forecast(weather.entity_id, forecast_type) + native_forecast_list = await weather.async_forecast_twice_daily() + if native_forecast_list is None: + converted_forecast_list = [] + else: + # pylint: disable-next=protected-access + converted_forecast_list = weather._convert_forecast(native_forecast_list) + return { + "forecast": converted_forecast_list, + } + + +class CoordinatorWeatherEntity( + CoordinatorEntity[_ObservationUpdateCoordinatorT], + WeatherEntity, + Generic[ + _ObservationUpdateCoordinatorT, + _DailyForecastUpdateCoordinatorT, + _HourlyForecastUpdateCoordinatorT, + _TwiceDailyForecastUpdateCoordinatorT, + ], +): + """A class for weather entities using DataUpdateCoordinators.""" + + def __init__( + self, + observation_coordinator: _ObservationUpdateCoordinatorT, + *, + context: Any = None, + daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None, + hourly_coordinator: _DailyForecastUpdateCoordinatorT | None = None, + twice_daily_coordinator: _DailyForecastUpdateCoordinatorT | None = None, + daily_forecast_valid: timedelta | None = None, + hourly_forecast_valid: timedelta | None = None, + twice_daily_forecast_valid: timedelta | None = None, + ) -> None: + """Initialize.""" + super().__init__(observation_coordinator, context) + self.forecast_coordinators = { + "daily": daily_coordinator, + "hourly": hourly_coordinator, + "twice_daily": twice_daily_coordinator, + } + self.forecast_valid = { + "daily": daily_forecast_valid, + "hourly": hourly_forecast_valid, + "twice_daily": twice_daily_forecast_valid, + } + self.unsub_forecast: dict[str, Callable[[], None] | None] = { + "daily": None, + "hourly": None, + "twice_daily": None, + } + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove(partial(self._remove_forecast_listener, "daily")) + self.async_on_remove(partial(self._remove_forecast_listener, "hourly")) + self.async_on_remove(partial(self._remove_forecast_listener, "twice_daily")) + + def _remove_forecast_listener( + self, forecast_type: Literal["daily", "hourly", "twice_daily"] + ) -> None: + """Remove weather forecast listener.""" + if unsub_fn := self.unsub_forecast[forecast_type]: + unsub_fn() + self.unsub_forecast[forecast_type] = None + + @callback + def _async_subscription_started( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """Start subscription to forecast_type.""" + if not (coordinator := self.forecast_coordinators[forecast_type]): + return + self.unsub_forecast[forecast_type] = coordinator.async_add_listener( + partial(self._handle_forecast_update, forecast_type) + ) + + @callback + def _handle_daily_forecast_coordinator_update(self) -> None: + """Handle updated data from the daily forecast coordinator.""" + + @callback + def _handle_hourly_forecast_coordinator_update(self) -> None: + """Handle updated data from the hourly forecast coordinator.""" + + @callback + def _handle_twice_daily_forecast_coordinator_update(self) -> None: + """Handle updated data from the twice daily forecast coordinator.""" + + @final + @callback + def _handle_forecast_update( + self, forecast_type: Literal["daily", "hourly", "twice_daily"] + ) -> None: + """Update forecast data.""" + coordinator = self.forecast_coordinators[forecast_type] + assert coordinator + assert coordinator.config_entry is not None + getattr(self, f"_handle_{forecast_type}_forecast_coordinator_update")() + coordinator.config_entry.async_create_task( + self.hass, self.async_update_listeners((forecast_type,)) + ) + + @callback + def _async_subscription_ended( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """End subscription to forecast_type.""" + self._remove_forecast_listener(forecast_type) + + @final + async def _async_refresh_forecast( + self, + coordinator: TimestampDataUpdateCoordinator[Any], + forecast_valid_time: timedelta | None, + ) -> bool: + """Refresh stale forecast if needed.""" + if coordinator.update_interval is None: + return True + if forecast_valid_time is None: + forecast_valid_time = coordinator.update_interval + if ( + not (last_success_time := coordinator.last_update_success_time) + or utcnow() - last_success_time >= coordinator.update_interval + ): + await coordinator.async_refresh() + if ( + not (last_success_time := coordinator.last_update_success_time) + or utcnow() - last_success_time >= forecast_valid_time + ): + return False + return True + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + raise NotImplementedError + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + raise NotImplementedError + + @callback + def _async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + raise NotImplementedError + + @final + async def _async_forecast( + self, forecast_type: Literal["daily", "hourly", "twice_daily"] + ) -> list[Forecast] | None: + """Return the forecast in native units.""" + coordinator = self.forecast_coordinators[forecast_type] + if coordinator and not await self._async_refresh_forecast( + coordinator, self.forecast_valid[forecast_type] + ): + return None + return cast( + list[Forecast] | None, getattr(self, f"_async_forecast_{forecast_type}")() + ) + + @final + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return await self._async_forecast("daily") + + @final + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return await self._async_forecast("hourly") + + @final + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + return await self._async_forecast("twice_daily") + + +class SingleCoordinatorWeatherEntity( + CoordinatorWeatherEntity[ + _ObservationUpdateCoordinatorT, + TimestampDataUpdateCoordinator[None], + TimestampDataUpdateCoordinator[None], + TimestampDataUpdateCoordinator[None], + ], +): + """A class for weather entities using a single DataUpdateCoordinators. + + This class is added as a convenience because: + - Deriving from CoordinatorWeatherEntity requires specifying all type parameters + until we upgrade to Python 3.12 which supports defaults + - Mypy bug https://github.com/python/mypy/issues/9424 prevents us from making the + forecast cooordinator type vars optional + """ + + def __init__( + self, + coordinator: _ObservationUpdateCoordinatorT, + context: Any = None, + ) -> None: + """Initialize.""" + super().__init__(coordinator, context=context) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + assert self.coordinator.config_entry + self.coordinator.config_entry.async_create_task( + self.hass, self.async_update_listeners(None) + ) diff --git a/homeassistant/components/weather/services.yaml b/homeassistant/components/weather/services.yaml new file mode 100644 index 00000000000..b2b71396fab --- /dev/null +++ b/homeassistant/components/weather/services.yaml @@ -0,0 +1,18 @@ +get_forecast: + target: + entity: + domain: weather + supported_features: + - weather.WeatherEntityFeature.FORECAST_DAILY + - weather.WeatherEntityFeature.FORECAST_HOURLY + - weather.WeatherEntityFeature.FORECAST_TWICE_DAILY + fields: + type: + required: true + selector: + select: + options: + - "daily" + - "hourly" + - "twice_daily" + translation_key: forecast_type diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 21029c77284..26388c217eb 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -77,5 +77,32 @@ } } } + }, + "selector": { + "forecast_type": { + "options": { + "daily": "Daily", + "hourly": "Hourly", + "twice_daily": "Twice daily" + } + } + }, + "services": { + "get_forecast": { + "name": "Get forecast", + "description": "Get weather forecast.", + "fields": { + "type": { + "name": "Forecast type", + "description": "Forecast type: daily, hourly or twice daily." + } + } + } + }, + "issues": { + "deprecated_weather_forecast": { + "title": "The {platform} integration is using deprecated forecast", + "description": "The integration `{platform}` is using the deprecated forecast attribute.\n\nPlease {report_issue}." + } } } diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index f2be4dfec6d..39a487dcb2f 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -9,6 +9,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util.json import JsonValueType from .const import DOMAIN, VALID_UNITS, WeatherEntityFeature @@ -80,7 +81,7 @@ async def ws_subscribe_forecast( return @callback - def forecast_listener(forecast: list[dict[str, Any]] | None) -> None: + def forecast_listener(forecast: list[JsonValueType] | None) -> None: """Push a new forecast to websocket.""" connection.send_message( websocket_api.event_message( diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c2851cb4c6e..61bef8c693c 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -8,11 +8,10 @@ from datetime import timedelta from functools import wraps from http import HTTPStatus import logging -from ssl import SSLContext +import ssl from typing import Any, Concatenate, ParamSpec, TypeVar, cast from aiowebostv import WebOsClient, WebOsTvPairError -import async_timeout from homeassistant import util from homeassistant.components.media_player import ( @@ -32,8 +31,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError 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 import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction @@ -476,13 +475,14 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): content = None ssl_context = None if url.startswith("https"): - ssl_context = SSLContext() + ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) websession = async_get_clientsession(self.hass) - with suppress(asyncio.TimeoutError), async_timeout.timeout(10): - response = await websession.get(url, ssl=ssl_context) - if response.status == HTTPStatus.OK: - content = await response.read() + with suppress(asyncio.TimeoutError): + async with asyncio.timeout(10): + response = await websession.get(url, ssl=ssl_context) + if response.status == HTTPStatus.OK: + content = await response.read() if content is None: _LOGGER.warning("Error retrieving proxied image from %s", url) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index bbcbfa6ecb8..7772bef66f9 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -5,6 +5,7 @@ from collections.abc import Callable import datetime as dt from functools import lru_cache, partial import json +import logging from typing import Any, cast import voluptuous as vol @@ -505,6 +506,7 @@ def _cached_template(template_str: str, hass: HomeAssistant) -> template.Templat vol.Optional("variables"): dict, vol.Optional("timeout"): vol.Coerce(float), vol.Optional("strict", default=False): bool, + vol.Optional("report_errors", default=False): bool, } ) @decorators.async_response @@ -513,15 +515,32 @@ async def handle_render_template( ) -> None: """Handle render_template command.""" template_str = msg["template"] - template_obj = _cached_template(template_str, hass) + report_errors: bool = msg["report_errors"] + if report_errors: + template_obj = template.Template(template_str, hass) + else: + template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") - info = None + + @callback + def _error_listener(level: int, template_error: str) -> None: + connection.send_message( + messages.event_message( + msg["id"], + {"error": template_error, "level": logging.getLevelName(level)}, + ) + ) + + @callback + def _thread_safe_error_listener(level: int, template_error: str) -> None: + hass.loop.call_soon_threadsafe(_error_listener, level, template_error) if timeout: try: + log_fn = _thread_safe_error_listener if report_errors else None timed_out = await template_obj.async_render_will_timeout( - timeout, variables, strict=msg["strict"] + timeout, variables, strict=msg["strict"], log_fn=log_fn ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) @@ -540,26 +559,31 @@ async def handle_render_template( event: EventType[EventStateChangedData] | None, updates: list[TrackTemplateResult], ) -> None: - nonlocal info track_template_result = updates.pop() result = track_template_result.result if isinstance(result, TemplateError): - connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(result)) + if not report_errors: + return + connection.send_message( + messages.event_message(msg["id"], {"error": str(result)}) + ) return connection.send_message( messages.event_message( - msg["id"], {"result": result, "listeners": info.listeners} # type: ignore[attr-defined] + msg["id"], {"result": result, "listeners": info.listeners} ) ) try: + log_fn = _error_listener if report_errors else None info = async_track_template_result( hass, [TrackTemplate(template_obj, variables)], _template_listener, raise_on_template_error=True, strict=msg["strict"], + log_fn=log_fn, ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) @@ -713,12 +737,12 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - response = await script_obj.async_run(msg.get("variables"), context=context) + script_result = await script_obj.async_run(msg.get("variables"), context=context) connection.send_result( msg["id"], { "context": context, - "response": response, + "response": script_result.service_response if script_result else None, }, ) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index f598906661c..1dbda62ab95 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -164,12 +164,12 @@ class ActiveConnection: if ( # Not using isinstance as we don't care about children # as these are always coming from JSON - type(msg) is not dict # pylint: disable=unidiomatic-typecheck + type(msg) is not dict # noqa: E721 or ( not (cur_id := msg.get("id")) - or type(cur_id) is not int # pylint: disable=unidiomatic-typecheck + or type(cur_id) is not int # noqa: E721 or not (type_ := msg.get("type")) - or type(type_) is not str # pylint: disable=unidiomatic-typecheck + or type(type_) is not str # noqa: E721 ) ): self.logger.error("Received invalid command: %s", msg) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index fcaa13ff8de..238cd6d7465 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -9,7 +9,6 @@ import logging from typing import TYPE_CHECKING, Any, Final from aiohttp import WSMsgType, web -import async_timeout from homeassistant.components.http import HomeAssistantView from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -273,7 +272,7 @@ class WebSocketHandler: logging_debug = logging.DEBUG try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await wsock.prepare(request) except asyncio.TimeoutError: self._logger.warning("Timeout preparing request from %s", request.remote) @@ -302,7 +301,7 @@ class WebSocketHandler: # Auth Phase try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): msg = await wsock.receive() except asyncio.TimeoutError as err: disconnect_warn = "Did not receive auth message within 10 seconds" diff --git a/homeassistant/components/wemo/entity.py b/homeassistant/components/wemo/entity.py index 1debc32a39b..cbb2f31c79d 100644 --- a/homeassistant/components/wemo/entity.py +++ b/homeassistant/components/wemo/entity.py @@ -7,7 +7,7 @@ import logging from pywemo.exceptions import ActionException -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .wemo_device import DeviceCoordinator diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index aaa85455c56..e1c8655c196 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -20,10 +20,7 @@ from homeassistant.util.percentage import ( ) from . import async_wemo_dispatcher_connect -from .const import ( - SERVICE_RESET_FILTER_LIFE, - SERVICE_SET_HUMIDITY, -) +from .const import SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY from .entity import WemoBinaryStateEntity from .wemo_device import DeviceCoordinator diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index fb01d117c08..0205a10521d 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -16,8 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index cb189116eeb..c0428e62b71 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.2.1"], + "requirements": ["pywemo==1.3.0"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index c85bc9fd473..110943a6503 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -24,9 +24,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import ( CONNECTION_UPNP, + DeviceInfo, async_get as async_get_device_registry, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index d1c5d6cf8f8..2d38d713859 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -26,7 +26,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo, generate_entity_id +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import WhirlpoolData diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index f761badfa2b..c3cad90e045 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 6333139e540..beca3540e8e 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import cast from whois import Domain @@ -16,13 +16,13 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util import dt as dt_util from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN @@ -46,7 +46,10 @@ def _days_until_expiration(domain: Domain) -> int | None: if domain.expiration_date is None: return None # We need to cast here, as (unlike Pyright) mypy isn't able to determine the type. - return cast(int, (domain.expiration_date - domain.expiration_date.utcnow()).days) + return cast( + int, + (domain.expiration_date - dt_util.utcnow().replace(tzinfo=None)).days, + ) def _ensure_timezone(timestamp: datetime | None) -> datetime | None: @@ -56,7 +59,7 @@ def _ensure_timezone(timestamp: datetime | None) -> datetime | None: # If timezone info isn't provided by the Whois, assume UTC. if timestamp.tzinfo is None: - return timestamp.replace(tzinfo=timezone.utc) + return timestamp.replace(tzinfo=UTC) return timestamp diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index a802535441a..11ef186ba15 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -10,11 +10,12 @@ from homeassistant.const import CONF_PORT, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.dt import utcnow diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 58ba237ae68..067197c8a14 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -8,7 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .parent_device import WiLightParent diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index ef3b6456d20..17e3c551bcc 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -38,12 +38,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( AbstractOAuth2Implementation, OAuth2Session, ) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import const -from .const import Measurement +from .const import DOMAIN, Measurement _LOGGER = logging.getLogger(const.LOG_NAMESPACE) _RETRY_COEFFICIENT = 0.5 @@ -561,6 +562,10 @@ class BaseWithingsSensor(Entity): description, data_manager.user_id ) self._state_data: Any | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(data_manager.user_id))}, + name=data_manager.profile, + ) @property def available(self) -> bool: diff --git a/homeassistant/components/wiz/entity.py b/homeassistant/components/wiz/entity.py index 67608db157a..87c3171d836 100644 --- a/homeassistant/components/wiz/entity.py +++ b/homeassistant/components/wiz/entity.py @@ -8,8 +8,8 @@ from pywizlight.bulblibrary import BulbType from homeassistant.const import ATTR_HW_VERSION, ATTR_MODEL from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/wled/models.py b/homeassistant/components/wled/models.py index 2bdd2e46e2c..81405190228 100644 --- a/homeassistant/components/wled/models.py +++ b/homeassistant/components/wled/models.py @@ -1,6 +1,5 @@ """Models for WLED.""" -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index d1ad8456bab..84ed67a36dd 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1,15 +1,28 @@ """Sensor to indicate whether the current day is a workday.""" from __future__ import annotations +from holidays import list_supported_countries + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError -from .const import PLATFORMS +from .const import CONF_COUNTRY, CONF_PROVINCE, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Workday from a config entry.""" + country: str = entry.options[CONF_COUNTRY] + province: str | None = entry.options.get(CONF_PROVINCE) + if country and country not in list_supported_countries(): + raise ConfigEntryError(f"Selected country {country} is not valid") + + if province and province not in list_supported_countries()[country]: + raise ConfigEntryError( + f"Selected province {province} for country {country} is not valid" + ) + 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/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index c80608ab1c2..ad18c8863d6 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -4,8 +4,13 @@ from __future__ import annotations from datetime import date, timedelta from typing import Any -import holidays -from holidays import DateLike, HolidayBase +from holidays import ( + DateLike, + HolidayBase, + __version__ as python_holidays_version, + country_holidays, + list_supported_countries, +) import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -16,8 +21,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 @@ -44,7 +48,6 @@ from .const import ( def valid_country(value: Any) -> str: """Validate that the given country is supported.""" value = cv.string(value) - all_supported_countries = holidays.list_supported_countries() try: raw_value = value.encode("utf-8") @@ -54,7 +57,7 @@ def valid_country(value: Any) -> str: ) from err if not raw_value: raise vol.Invalid("Country name or the abbreviation must not be empty.") - if value not in all_supported_countries: + if value not in list_supported_countries(): raise vol.Invalid("Country is not supported.") return value @@ -124,17 +127,15 @@ async def async_setup_entry( province: str | None = entry.options.get(CONF_PROVINCE) sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] - - cls: HolidayBase = getattr(holidays, country) year: int = (dt_util.now() + timedelta(days=days_offset)).year - if province and province not in cls.subdivisions: - LOGGER.error("There is no subdivision %s in country %s", province, country) - return - - obj_holidays = cls( - subdiv=province, years=year, language=cls.default_language - ) # type: ignore[operator] + cls: HolidayBase = country_holidays(country, subdiv=province, years=year) + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=cls.default_language, + ) # Add custom holidays try: @@ -210,7 +211,7 @@ class IsWorkdaySensor(BinarySensorEntity): entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, manufacturer="python-holidays", - model=holidays.__version__, + model=python_holidays_version, name=name, ) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 15e04ffca93..54c6196b75b 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -3,8 +3,7 @@ from __future__ import annotations from typing import Any -import holidays -from holidays import HolidayBase, list_supported_countries +from holidays import HolidayBase, country_holidays, list_supported_countries import voluptuous as vol from homeassistant.config_entries import ( @@ -77,12 +76,14 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: if dt_util.parse_date(add_date) is None: raise AddDatesError("Incorrect date") - cls: HolidayBase = getattr(holidays, user_input[CONF_COUNTRY]) + cls: HolidayBase = country_holidays(user_input[CONF_COUNTRY]) year: int = dt_util.now().year - - obj_holidays = cls( - subdiv=user_input.get(CONF_PROVINCE), years=year, language=cls.default_language - ) # type: ignore[operator] + obj_holidays: HolidayBase = country_holidays( + user_input[CONF_COUNTRY], + subdiv=user_input.get(CONF_PROVINCE), + years=year, + language=cls.default_language, + ) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: if dt_util.parse_date(remove_date) is None: diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py index 776f6c6e20f..1a5c7ae39a2 100644 --- a/homeassistant/components/worldtidesinfo/sensor.py +++ b/homeassistant/components/worldtidesinfo/sensor.py @@ -115,8 +115,8 @@ class WorldTidesInfoSensor(SensorEntity): start = int(time.time()) resource = ( "https://www.worldtides.info/api?extremes&length=86400" - "&key={}&lat={}&lon={}&start={}" - ).format(self._key, self._lat, self._lon, start) + f"&key={self._key}&lat={self._lat}&lon={self._lon}&start={start}" + ) try: self.data = requests.get(resource, timeout=10).json() diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 834a0b95f42..111acc5fff6 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -5,7 +5,6 @@ import asyncio import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -95,7 +94,7 @@ class WorxLandroidSensor(SensorEntity): try: session = async_get_clientsession(self.hass) - async with async_timeout.timeout(self.timeout): + async with asyncio.timeout(self.timeout): auth = aiohttp.helpers.BasicAuth("admin", self.pin) mower_response = await session.get(self.url, auth=auth) except (asyncio.TimeoutError, aiohttp.ClientError): diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index 0bf58c249ae..b5c87fbc0f3 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -8,7 +8,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index d7d5d0278e8..f6b8ed73890 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -50,14 +50,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors={"base": "cannot_connect"}, ) - # ASR = automated speech recognition (STT) + # ASR = automated speech recognition (speech-to-text) asr_installed = [asr for asr in service.info.asr if asr.installed] + + # TTS = text-to-speech tts_installed = [tts for tts in service.info.tts if tts.installed] + # wake-word-detection + wake_installed = [wake for wake in service.info.wake if wake.installed] + if asr_installed: name = asr_installed[0].name elif tts_installed: name = tts_installed[0].name + elif wake_installed: + name = wake_installed[0].name else: return self.async_abort(reason="no_services") @@ -86,9 +93,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if not any( - asr for asr in service.info.asr if asr.installed - ) and not any(tts for tts in service.info.tts if tts.installed): + if ( + not any(asr for asr in service.info.asr if asr.installed) + and not any(tts for tts in service.info.tts if tts.installed) + and not any(wake for wake in service.info.wake if wake.installed) + ): return self.async_abort(reason="no_services") return self.async_create_entry( diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index c2d71835c65..64b92eb8471 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import async_timeout from wyoming.client import AsyncTcpClient from wyoming.info import Describe, Info @@ -29,6 +28,8 @@ class WyomingService: platforms.append(Platform.STT) if any(tts.installed for tts in info.tts): platforms.append(Platform.TTS) + if any(wake.installed for wake in info.wake): + platforms.append(Platform.WAKE_WORD) self.platforms = platforms @classmethod @@ -53,9 +54,7 @@ async def load_wyoming_info( for _ in range(retries + 1): try: - async with AsyncTcpClient(host, port) as client, async_timeout.timeout( - timeout - ): + async with AsyncTcpClient(host, port) as client, asyncio.timeout(timeout): # Describe -> Info await client.write_event(Describe().event()) while True: diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py new file mode 100644 index 00000000000..0e7fb3c4429 --- /dev/null +++ b/homeassistant/components/wyoming/wake_word.py @@ -0,0 +1,157 @@ +"""Support for Wyoming wake-word-detection services.""" +import asyncio +from collections.abc import AsyncIterable +import logging + +from wyoming.audio import AudioChunk, AudioStart +from wyoming.client import AsyncTcpClient +from wyoming.wake import Detection + +from homeassistant.components import wake_word +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .data import WyomingService +from .error import WyomingError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming wake-word-detection.""" + service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [ + WyomingWakeWordProvider(config_entry, service), + ] + ) + + +class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): + """Wyoming wake-word-detection provider.""" + + def __init__( + self, + config_entry: ConfigEntry, + service: WyomingService, + ) -> None: + """Set up provider.""" + self.service = service + wake_service = service.info.wake[0] + + self._supported_wake_words = [ + wake_word.WakeWord(ww_id=ww.name, name=ww.name) + for ww in wake_service.models + ] + self._attr_name = wake_service.name + self._attr_unique_id = f"{config_entry.entry_id}-wake_word" + + @property + def supported_wake_words(self) -> list[wake_word.WakeWord]: + """Return a list of supported wake words.""" + return self._supported_wake_words + + async def _async_process_audio_stream( + self, stream: AsyncIterable[tuple[bytes, int]] + ) -> wake_word.DetectionResult | None: + """Try to detect one or more wake words in an audio stream. + + Audio must be 16Khz sample rate with 16-bit mono PCM samples. + """ + + async def next_chunk(): + """Get the next chunk from audio stream.""" + async for chunk_bytes in stream: + return chunk_bytes + + try: + async with AsyncTcpClient(self.service.host, self.service.port) as client: + await client.write_event( + AudioStart( + rate=16000, + width=2, + channels=1, + ).event(), + ) + + # Read audio and wake events in "parallel" + audio_task = asyncio.create_task(next_chunk()) + wake_task = asyncio.create_task(client.read_event()) + pending = {audio_task, wake_task} + + try: + while True: + done, pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED + ) + + if wake_task in done: + event = wake_task.result() + if event is None: + _LOGGER.debug("Connection lost") + break + + if Detection.is_type(event.type): + # Successful detection + detection = Detection.from_event(event) + _LOGGER.info(detection) + + # Retrieve queued audio + queued_audio: list[tuple[bytes, int]] | None = None + if audio_task in pending: + # Save queued audio + await audio_task + pending.remove(audio_task) + queued_audio = [audio_task.result()] + + return wake_word.DetectionResult( + ww_id=detection.name, + timestamp=detection.timestamp, + queued_audio=queued_audio, + ) + + # Next event + wake_task = asyncio.create_task(client.read_event()) + pending.add(wake_task) + + if audio_task in done: + # Forward audio to wake service + chunk_info = audio_task.result() + if chunk_info is None: + break + + chunk_bytes, chunk_timestamp = chunk_info + chunk = AudioChunk( + rate=16000, + width=2, + channels=1, + audio=chunk_bytes, + timestamp=chunk_timestamp, + ) + await client.write_event(chunk.event()) + + # Next chunk + audio_task = asyncio.create_task(next_chunk()) + pending.add(audio_task) + finally: + # Clean up + if audio_task in pending: + # It's critical that we don't cancel the audio task or + # leave it hanging. This would mess up the pipeline STT + # by stopping the audio stream. + await audio_task + pending.remove(audio_task) + + for task in pending: + task.cancel() + + except (OSError, WyomingError) as err: + _LOGGER.exception("Error processing audio stream: %s", err) + + return None diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py index 8d0016590ed..ffbbee8637d 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/base_sensor.py @@ -3,8 +3,7 @@ from __future__ import annotations from yarl import URL -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PresenceData, XboxUpdateCoordinator diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index ab16afa9280..060720338e8 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -22,7 +22,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index 75595483608..fdb4e80cf9e 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -22,7 +22,7 @@ from homeassistant.components.remote import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index ef36bd67778..f7bc1910521 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -23,8 +23,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import utcnow @@ -267,10 +267,8 @@ class XiaomiDevice(Entity): self.parse_data(device["data"], device["raw_data"]) self.parse_voltage(device["data"]) - if hasattr(self, "_data_key") and self._data_key: # pylint: disable=no-member - self._unique_id = ( - f"{self._data_key}{self._sid}" # pylint: disable=no-member - ) + if hasattr(self, "_data_key") and self._data_key: + self._unique_id = f"{self._data_key}{self._sid}" else: self._unique_id = f"{self._type}{self._sid}" diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 1810d52323c..b12f4df7db1 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry, async_get from .const import ( CONF_DISCOVERED_EVENT_CLASSES, + CONF_SLEEPY_DEVICE, DOMAIN, XIAOMI_BLE_EVENT, XiaomiBleEvent, @@ -43,6 +44,11 @@ def process_service_info( entry.entry_id ] discovered_device_classes = coordinator.discovered_device_classes + if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device: + hass.config_entries.async_update_entry( + entry, + data=entry.data | {CONF_SLEEPY_DEVICE: data.sleepy_device}, + ) if update.events: address = service_info.device.address for device_key, event in update.events.items(): @@ -157,6 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # since we will trade the BLEDevice for a connectable one # if we need to poll it connectable=False, + entry=entry, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index f7c4c87014c..2894b8d2f3f 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -72,6 +72,9 @@ BINARY_SENSOR_DESCRIPTIONS = { key=ExtendedBinarySensorDeviceClass.PRY_THE_DOOR, device_class=BinarySensorDeviceClass.TAMPER, ), + ExtendedBinarySensorDeviceClass.TOOTHBRUSH: BinarySensorEntityDescription( + key=ExtendedBinarySensorDeviceClass.TOOTHBRUSH, + ), } @@ -119,7 +122,9 @@ async def async_setup_entry( XiaomiBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, BinarySensorEntityDescription) + ) class XiaomiBluetoothSensorEntity( @@ -136,7 +141,4 @@ class XiaomiBluetoothSensorEntity( @property def available(self) -> bool: """Return True if entity is available.""" - coordinator: XiaomiActiveBluetoothProcessorCoordinator = ( - self.processor.coordinator - ) - return coordinator.device_data.sleepy_device or super().available + return self.processor.coordinator.sleepy_device or super().available diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 1566478bcea..346d8a61318 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -7,6 +7,7 @@ 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" EVENT_TYPE: Final = "event_type" diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index 2a4b35f6171..94e70ca9835 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -15,9 +15,12 @@ from homeassistant.components.bluetooth.active_update_processor import ( from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer +from .const import CONF_SLEEPY_DEVICE + class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): """Define a Xiaomi Bluetooth Active Update Processor Coordinator.""" @@ -39,6 +42,7 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina ] | None = None, poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, + entry: ConfigEntry, connectable: bool = True, ) -> None: """Initialize the Xiaomi Bluetooth Active Update Processor Coordinator.""" @@ -55,6 +59,12 @@ class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordina ) self.discovered_device_classes = discovered_device_classes self.device_data = device_data + self.entry = entry + + @property + def sleepy_device(self) -> bool: + """Return True if the device is a sleepy device.""" + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) class XiaomiPassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index e2b327c6823..a03e3f388ed 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.20.0"] + "requirements": ["xiaomi-ble==0.21.1"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index f0f0d7fa71e..cdb7b3a8fd8 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from xiaomi_ble import DeviceClass, SensorUpdate, Units +from xiaomi_ble.parser import ExtendedSensorDeviceClass from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( @@ -25,6 +26,7 @@ from homeassistant.const import ( UnitOfMass, UnitOfPressure, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -129,13 +131,23 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), - # Used for e.g. consumable sensor on WX08ZM - (None, Units.PERCENTAGE): SensorEntityDescription( - key=str(Units.PERCENTAGE), - device_class=None, + # Used for e.g. consumable sensor on WX08ZM and M1S-T500 + (ExtendedSensorDeviceClass.CONSUMABLE, Units.PERCENTAGE): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.CONSUMABLE), native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + # Used for score after brushing with a toothbrush + (ExtendedSensorDeviceClass.SCORE, None): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.SCORE), + state_class=SensorStateClass.MEASUREMENT, + ), + # Used for counting during brushing + (ExtendedSensorDeviceClass.COUNTER, Units.TIME_SECONDS): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.COUNTER), + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + ), } @@ -153,7 +165,7 @@ def sensor_update_to_bluetooth_data_update( (description.device_class, description.native_unit_of_measurement) ] for device_key, description in sensor_update.entity_descriptions.items() - if description.native_unit_of_measurement + if description.device_class }, entity_data={ device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value @@ -183,7 +195,9 @@ async def async_setup_entry( XiaomiBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class XiaomiBluetoothSensorEntity( @@ -200,7 +214,4 @@ class XiaomiBluetoothSensorEntity( @property def available(self) -> bool: """Return True if entity is available.""" - coordinator: XiaomiActiveBluetoothProcessorCoordinator = ( - self.processor.coordinator - ) - return coordinator.device_data.sleepy_device or super().available + return self.processor.coordinator.sleepy_device or super().available diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 541b077f6f0..0291ca2c8bd 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -1,13 +1,13 @@ """Support for Xiaomi Miio.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta import logging from typing import Any -import async_timeout from miio import ( AirFresh, AirFreshA1, @@ -176,7 +176,7 @@ def _async_update_data_default(hass, device): async def _async_fetch_data(): """Fetch data from the device.""" - async with async_timeout.timeout(POLLING_TIMEOUT_SEC): + async with asyncio.timeout(POLLING_TIMEOUT_SEC): state = await hass.async_add_executor_job(device.status) _LOGGER.debug("Got new state: %s", state) return state @@ -265,7 +265,7 @@ def _async_update_data_vacuum( """Fetch data from the device using async_add_executor_job.""" async def execute_update() -> VacuumCoordinatorData: - async with async_timeout.timeout(POLLING_TIMEOUT_SEC): + async with asyncio.timeout(POLLING_TIMEOUT_SEC): state = await hass.async_add_executor_job(update) _LOGGER.debug("Got new vacuum state: %s", state) return state diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index b5057a4a3dd..e92dd76be39 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -17,7 +17,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_GATEWAY, DOMAIN diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index 81ca71d6b68..da860c7045e 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -10,7 +10,8 @@ from miio import Device, DeviceException from homeassistant.const import ATTR_CONNECTIONS, CONF_MODEL from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index 655a04a4340..e1b3aee9ff4 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -7,7 +7,8 @@ from micloud.micloudexception import MiCloudAccessDenied from miio import DeviceException, gateway from miio.gateway.gateway import GATEWAY_MODEL_EU -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 9b8357a534f..1fc032b5c36 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -36,7 +36,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color, dt as dt_util @@ -449,14 +449,11 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: _LOGGER.debug( - ( - "Setting brightness and color temperature: " - "%s %s%%, %s mireds, %s%% cct" - ), + "Setting brightness and color temperature: %s %s%%, %s mireds, %s%% cct", brightness, - percent_brightness, # pylint: disable=used-before-assignment + percent_brightness, color_temp, - percent_color_temp, # pylint: disable=used-before-assignment + percent_color_temp, ) result = await self._try_command( @@ -832,8 +829,8 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): _LOGGER.debug( "Setting brightness and color: %s %s%%, %s", brightness, - percent_brightness, # pylint: disable=used-before-assignment - rgb, # pylint: disable=used-before-assignment + percent_brightness, + rgb, ) result = await self._try_command( @@ -856,7 +853,7 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): brightness, percent_brightness, color_temp, - percent_color_temp, # pylint: disable=used-before-assignment + percent_color_temp, ) result = await self._try_command( diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 86c7905848a..17d60e1a952 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -42,7 +42,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index 2f5bad116c4..0150e761838 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -198,9 +198,7 @@ async def async_send_message( # noqa: C901 _LOGGER.info("Sending file to %s", recipient) message = self.Message(sto=recipient, stype="chat") message["body"] = url - message["oob"][ # pylint: disable=invalid-sequence-index - "url" - ] = url + message["oob"]["url"] = url try: message.send() except (IqError, IqTimeout, XMPPError) as ex: diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 763742cce70..830d8d9f69e 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -1,11 +1,14 @@ """The yale_smart_alarm component.""" from __future__ import annotations +from homeassistant.components.lock import CONF_DEFAULT_CODE, DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import entity_registry as er -from .const import COORDINATOR, DOMAIN, PLATFORMS +from .const import COORDINATOR, DOMAIN, LOGGER, PLATFORMS from .coordinator import YaleDataUpdateCoordinator @@ -39,3 +42,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return True return False + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + if config_entry_default_code := entry.options.get(CONF_CODE): + entity_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entries: + if entity.entity_id.startswith("lock"): + entity_reg.async_update_entity_options( + entity.entity_id, + LOCK_DOMAIN, + {CONF_DEFAULT_CODE: config_entry_default_code}, + ) + new_options = entry.options.copy() + del new_options[CONF_CODE] + + hass.config_entries.async_update_entry(entry, options=new_options) + + entry.version = 2 + + LOGGER.info("Migration to version %s successful", entry.version) + + return True diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index a2462df41cb..ff813d43d78 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -9,7 +9,7 @@ from yalesmartalarmclient.client import YaleSmartAlarmClient from yalesmartalarmclient.exceptions import AuthenticationError from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv @@ -44,7 +44,7 @@ DATA_SCHEMA_AUTH = vol.Schema( class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" - VERSION = 1 + VERSION = 2 entry: ConfigEntry | None @@ -155,32 +155,22 @@ class YaleOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage Yale options.""" - errors = {} + errors: dict[str, Any] = {} if user_input: - if len(user_input.get(CONF_CODE, "")) not in [ - 0, - user_input[CONF_LOCK_CODE_DIGITS], - ]: - errors["base"] = "code_format_mismatch" - else: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", data_schema=vol.Schema( { - vol.Optional( - CONF_CODE, - description={ - "suggested_value": self.entry.options.get(CONF_CODE) - }, - ): str, vol.Optional( CONF_LOCK_CODE_DIGITS, - default=self.entry.options.get( - CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS - ), + description={ + "suggested_value": self.entry.options.get( + CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS + ) + }, ): int, } ), diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 1a350d1db98..e1cff8fb2a5 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, YALE_BASE_ERRORS -class YaleDataUpdateCoordinator(DataUpdateCoordinator): +class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """A Yale Data Update Coordinator.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -28,6 +28,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator): LOGGER, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + always_update=False, ) async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py index 86b5839b51f..179e20d509d 100644 --- a/homeassistant/components/yale_smart_alarm/entity.py +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -1,8 +1,8 @@ """Base class for yale_smart_alarm entity.""" from homeassistant.const import CONF_NAME, CONF_USERNAME -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, MODEL diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 397a9cc8db1..50d7b28c52b 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CODE, CONF_CODE +from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -52,9 +52,7 @@ class YaleDoorlock(YaleEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" - code: str | None = kwargs.get( - ATTR_CODE, self.coordinator.entry.options.get(CONF_CODE) - ) + code: str | None = kwargs.get(ATTR_CODE) return await self.async_set_lock("unlocked", code) async def async_lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index ec0c5d0702a..a51d151d7d9 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -31,13 +31,9 @@ "step": { "init": { "data": { - "code": "Default code for locks, used if none is given", "lock_code_digits": "Number of digits in PIN code for locks" } } - }, - "error": { - "code_format_mismatch": "The code does not match the required number of digits" } }, "entity": { diff --git a/homeassistant/components/yalexs_ble/entity.py b/homeassistant/components/yalexs_ble/entity.py index 51f30b8a861..9135f0c0896 100644 --- a/homeassistant/components/yalexs_ble/entity.py +++ b/homeassistant/components/yalexs_ble/entity.py @@ -6,7 +6,8 @@ from yalexs_ble import ConnectionInfo, LockInfo, LockState from homeassistant.components import bluetooth from homeassistant.core import callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN from .models import YaleXSBLEData diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 639f0b69a41..c3851074365 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -13,8 +13,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 755207c272d..481678100de 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -4,7 +4,6 @@ from http import HTTPStatus import logging import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -120,7 +119,7 @@ class YandexSpeechKitProvider(Provider): actual_language = language try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): url_param = { "text": message, "lang": actual_language, diff --git a/homeassistant/components/yardian/__init__.py b/homeassistant/components/yardian/__init__.py new file mode 100644 index 00000000000..d6cee9015b8 --- /dev/null +++ b/homeassistant/components/yardian/__init__.py @@ -0,0 +1,40 @@ +"""The Yardian integration.""" +from __future__ import annotations + +from pyyardian import AsyncYardianClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import YardianUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Yardian from a config entry.""" + + host = entry.data[CONF_HOST] + access_token = entry.data[CONF_ACCESS_TOKEN] + + controller = AsyncYardianClient(async_get_clientsession(hass), host, access_token) + coordinator = YardianUpdateCoordinator(hass, entry, controller) + 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.get(DOMAIN, {}).pop(entry.entry_id, None) + + return unload_ok diff --git a/homeassistant/components/yardian/config_flow.py b/homeassistant/components/yardian/config_flow.py new file mode 100644 index 00000000000..99258965f21 --- /dev/null +++ b/homeassistant/components/yardian/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Yardian integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyyardian import ( + AsyncYardianClient, + DeviceInfo, + NetworkException, + NotAuthorizedException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, PRODUCT_NAME + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_ACCESS_TOKEN): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Yardian.""" + + VERSION = 1 + + async def async_fetch_device_info(self, host: str, access_token: str) -> DeviceInfo: + """Fetch device info from Yardian.""" + yarcli = AsyncYardianClient( + async_get_clientsession(self.hass), + host, + access_token, + ) + return await yarcli.fetch_device_info() + + 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: + try: + device_info = await self.async_fetch_device_info( + user_input["host"], user_input["access_token"] + ) + except NotAuthorizedException: + errors["base"] = "invalid_auth" + except NetworkException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device_info["yid"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + data=user_input | device_info, + title=PRODUCT_NAME, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/yardian/const.py b/homeassistant/components/yardian/const.py new file mode 100644 index 00000000000..b4e75f2367b --- /dev/null +++ b/homeassistant/components/yardian/const.py @@ -0,0 +1,7 @@ +"""Constants for the Yardian integration.""" + +DOMAIN = "yardian" +MANUFACTURER = "Aeon Matrix" +PRODUCT_NAME = "Yardian Smart Sprinkler" + +DEFAULT_WATERING_DURATION = 6 diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py new file mode 100644 index 00000000000..526ee3c42ab --- /dev/null +++ b/homeassistant/components/yardian/coordinator.py @@ -0,0 +1,73 @@ +"""Update coordinators for Yardian.""" + +from __future__ import annotations + +import asyncio +import datetime +import logging + +from pyyardian import ( + AsyncYardianClient, + NetworkException, + NotAuthorizedException, + YardianDeviceState, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=30) + + +class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): + """Coordinator for Yardian API calls.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + controller: AsyncYardianClient, + ) -> None: + """Initialize Yardian API communication.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + update_method=self._async_update_data, + update_interval=SCAN_INTERVAL, + always_update=False, + ) + + self.controller = controller + self.yid = entry.data["yid"] + self._name = entry.title + self._model = entry.data["model"] + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return DeviceInfo( + name=self._name, + identifiers={(DOMAIN, self.yid)}, + manufacturer=MANUFACTURER, + model=self._model, + ) + + async def _async_update_data(self) -> YardianDeviceState: + """Fetch data from Yardian device.""" + try: + async with asyncio.timeout(10): + return await self.controller.fetch_device_state() + + except asyncio.TimeoutError as e: + raise UpdateFailed("Communication with Device was time out") from e + except NotAuthorizedException as e: + raise UpdateFailed("Invalid access token") from e + except NetworkException as e: + raise UpdateFailed("Failed to communicate with Device") from e diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json new file mode 100644 index 00000000000..a20315278b4 --- /dev/null +++ b/homeassistant/components/yardian/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "yardian", + "name": "Yardian", + "codeowners": ["@h3l1o5"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/yardian", + "iot_class": "local_polling", + "requirements": ["pyyardian==1.1.0"] +} diff --git a/homeassistant/components/yardian/strings.json b/homeassistant/components/yardian/strings.json new file mode 100644 index 00000000000..6577c99456c --- /dev/null +++ b/homeassistant/components/yardian/strings.json @@ -0,0 +1,20 @@ +{ + "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_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/yardian/switch.py b/homeassistant/components/yardian/switch.py new file mode 100644 index 00000000000..af5703e0fd4 --- /dev/null +++ b/homeassistant/components/yardian/switch.py @@ -0,0 +1,71 @@ +"""Support for Yardian integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_WATERING_DURATION, DOMAIN +from .coordinator import YardianUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entry for a Yardian irrigation switches.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + YardianSwitch( + coordinator, + i, + ) + for i in range(len(coordinator.data.zones)) + ) + + +class YardianSwitch(CoordinatorEntity[YardianUpdateCoordinator], SwitchEntity): + """Representation of a Yardian switch.""" + + _attr_icon = "mdi:water" + _attr_has_entity_name = True + + def __init__(self, coordinator: YardianUpdateCoordinator, zone_id) -> None: + """Initialize a Yardian Switch Device.""" + super().__init__(coordinator) + self._zone_id = zone_id + self._attr_unique_id = f"{coordinator.yid}-{zone_id}" + self._attr_device_info = coordinator.device_info + + @property + def name(self) -> str: + """Return the zone name.""" + return self.coordinator.data.zones[self._zone_id][0] + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._zone_id in self.coordinator.data.active_zones + + @property + def available(self) -> bool: + """Return the switch is available or not.""" + return self.coordinator.data.zones[self._zone_id][1] == 1 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.controller.start_irrigation( + self._zone_id, + kwargs.get("duration", DEFAULT_WATERING_DURATION), + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.controller.stop_irrigation() + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index c07852629a9..cc9faa33194 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -144,6 +144,7 @@ async def _async_initialize( entry: ConfigEntry, device: YeelightDevice, ) -> None: + """Initialize a Yeelight device.""" entry_data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = {} await device.async_setup() entry_data[DATA_DEVICE] = device @@ -216,6 +217,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, OSError, BulbException) as ex: raise ConfigEntryNotReady from ex + found_unique_id = device.unique_id + expected_unique_id = entry.unique_id + if expected_unique_id and found_unique_id and found_unique_id != expected_unique_id: + # If the id of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {device.host}; " + f"expected {expected_unique_id}, found {found_unique_id}" + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Wait to install the reload listener until everything was successfully initialized diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py index 0fabe693aa9..811a1904b04 100644 --- a/homeassistant/components/yeelight/device.py +++ b/homeassistant/components/yeelight/device.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio import logging +from typing import Any from yeelight import BulbException -from yeelight.aio import KEY_CONNECTED +from yeelight.aio import KEY_CONNECTED, AsyncBulb from homeassistant.const import CONF_ID, CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -63,17 +64,19 @@ def update_needs_bg_power_workaround(data): class YeelightDevice: """Represents single Yeelight device.""" - def __init__(self, hass, host, config, bulb): + def __init__( + self, hass: HomeAssistant, host: str, config: dict[str, Any], bulb: AsyncBulb + ) -> None: """Initialize device.""" self._hass = hass self._config = config self._host = host self._bulb_device = bulb - self.capabilities = {} - self._device_type = None + self.capabilities: dict[str, Any] = {} + self._device_type: str | None = None self._available = True self._initialized = False - self._name = None + self._name: str | None = None @property def bulb(self): @@ -115,6 +118,11 @@ class YeelightDevice: """Return the firmware version.""" return self.capabilities.get("fw_ver") + @property + def unique_id(self) -> str | None: + """Return the unique ID of the device.""" + return self.capabilities.get("id") + @property def is_nightlight_supported(self) -> bool: """Return true / false if nightlight is supported. diff --git a/homeassistant/components/yeelight/entity.py b/homeassistant/components/yeelight/entity.py index 9422ec9980d..8056ea085b7 100644 --- a/homeassistant/components/yeelight/entity.py +++ b/homeassistant/components/yeelight/entity.py @@ -2,7 +2,8 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from .const import DOMAIN from .device import YeelightDevice diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 766ac0700e5..993cc6ca4fa 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.34.1"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 7c6bbd2d2ee..43e976eeeac 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -10,7 +10,6 @@ import logging from typing import Self from urllib.parse import urlparse -import async_timeout from async_upnp_client.search import SsdpSearchListener from async_upnp_client.utils import CaseInsensitiveDict @@ -157,7 +156,7 @@ class YeelightScanner: listener.async_search((host, SSDP_TARGET[1])) with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(DISCOVERY_TIMEOUT): + async with asyncio.timeout(DISCOVERY_TIMEOUT): await host_event.wait() self._host_discovered_events[host].remove(host_event) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index c3633800685..20129b819ce 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -import async_timeout from yolink.const import ATTR_DEVICE_SMART_REMOTER from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError @@ -111,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) yolink_home = YoLinkHome() try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): await yolink_home.async_setup( auth_mgr, YoLinkHomeMessageListener(hass, entry) ) diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index e322961d179..9055b2d044e 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -1,10 +1,10 @@ """YoLink DataUpdateCoordinator.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging -import async_timeout from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError @@ -41,7 +41,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): async def _async_update_data(self) -> dict: """Fetch device state.""" try: - async with async_timeout.timeout(10): + async with asyncio.timeout(10): device_state_resp = await self.device.fetch_state() device_state = device_state_resp.data.get(ATTR_DEVICE_STATE) if self.paired_device is not None and device_state is not None: diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 09da5545d57..0221bd94a7e 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -9,7 +9,7 @@ from yolink.exception import YoLinkAuthFailError, YoLinkClientError from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index 057533081e6..36175ae9cf3 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +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 ( diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 50dee14d61a..cf0d61b5d38 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -145,6 +145,8 @@ class OAuth2FlowHandler( ) async for subscription in youtube.get_user_subscriptions() ] + if not selectable_channels: + return self.async_abort(reason="no_subscriptions") return self.async_show_form( step_id="channels", data_schema=vol.Schema( diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index 46deaf40450..6f7f0b28dd2 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -1,8 +1,8 @@ """Entity representing a YouTube account.""" from __future__ import annotations -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_TITLE, DOMAIN, MANUFACTURER diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index ccb7e9c506e..1b9ecbc1cb3 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -5,6 +5,7 @@ "no_channel": "Please create a YouTube channel to be able to use the integration. Instructions can be found at {support_url}.", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "no_subscriptions": "You need to be subscribed to YouTube channels in order to add them.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index 3ff7612d47e..df17672231e 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.2.4"] + "requirements": ["zamg==0.3.0"] } diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index f3e49447056..31275dd908d 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -21,8 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +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 diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index f94f9ca8a3a..ff98496bd40 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -10,8 +10,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index cd7b9e95e75..26577bd0bbe 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.74.0"] + "requirements": ["zeroconf==0.91.1"] } diff --git a/homeassistant/components/zerproc/config_flow.py b/homeassistant/components/zerproc/config_flow.py index e68c51cd7eb..a9fd20ce241 100644 --- a/homeassistant/components/zerproc/config_flow.py +++ b/homeassistant/components/zerproc/config_flow.py @@ -17,7 +17,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: devices = await pyzerproc.discover() return len(devices) > 0 except pyzerproc.ZerprocException: - _LOGGER.error("Unable to discover nearby Zerproc devices", exc_info=True) + _LOGGER.exception("Unable to discover nearby Zerproc devices") return False diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 41ecb751b86..884f87d36f6 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util diff --git a/homeassistant/components/zeversolar/entity.py b/homeassistant/components/zeversolar/entity.py index ccda0add910..77ae5ee61f8 100644 --- a/homeassistant/components/zeversolar/entity.py +++ b/homeassistant/components/zeversolar/entity.py @@ -1,7 +1,7 @@ """Base Entity for Zeversolar sensors.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 8a81648b580..f9113ebaa90 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -10,15 +10,16 @@ from zhaquirks import setup as setup_quirks from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from . import websocket_api +from . import repairs, websocket_api from .core import ZHAGateway from .core.const import ( BAUD_RATES, @@ -33,7 +34,6 @@ from .core.const import ( DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY, - DATA_ZHA_SHUTDOWN_TASK, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, @@ -134,8 +134,28 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: _LOGGER.debug("ZHA storage file does not exist or was already removed") - zha_gateway = ZHAGateway(hass, config, config_entry) - await zha_gateway.async_initialize() + # Re-use the gateway object between ZHA reloads + if (zha_gateway := zha_data.get(DATA_ZHA_GATEWAY)) is None: + zha_gateway = ZHAGateway(hass, config, config_entry) + + try: + await zha_gateway.async_initialize() + except Exception: # pylint: disable=broad-except + if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: + try: + await repairs.warn_on_wrong_silabs_firmware( + hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + ) + except repairs.AlreadyRunningEZSP as exc: + # If connecting fails but we somehow probe EZSP (e.g. stuck in the + # bootloader), reconnect, it should work + raise ConfigEntryNotReady from exc + + raise + + repairs.async_delete_blocking_issues(hass) + + config_entry.async_on_unload(zha_gateway.shutdown) device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -149,15 +169,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b websocket_api.async_load_api(hass) - async def async_zha_shutdown(event): - """Handle shutdown tasks.""" - zha_gateway: ZHAGateway = zha_data[DATA_ZHA_GATEWAY] - await zha_gateway.shutdown() - - zha_data[DATA_ZHA_SHUTDOWN_TASK] = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, async_zha_shutdown - ) - await zha_gateway.async_initialize_devices_and_entities() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) @@ -166,8 +177,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY) - await zha_gateway.shutdown() + try: + del hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + except KeyError: + return False GROUP_PROBE.cleanup() websocket_api.async_unload_api(hass) @@ -180,8 +193,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) ) - hass.data[DATA_ZHA][DATA_ZHA_SHUTDOWN_TASK]() - return True diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 48fbf1f0bb2..50cfb783370 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -265,6 +265,7 @@ class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): SENSOR_ATTR = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "Replace filter" diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index b3b6e7f0483..7a4132115b8 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -6,9 +6,6 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self -import zigpy.exceptions -from zigpy.zcl.foundation import Status - from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform @@ -134,17 +131,10 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): async def async_press(self) -> None: """Write attribute with defined value.""" - try: - result = await self._cluster_handler.cluster.write_attributes( - {self._attribute_name: self._attribute_value} - ) - except zigpy.exceptions.ZigbeeException as ex: - self.error("Could not set value: %s", ex) - return - if not isinstance(result, Exception) and all( - record.status == Status.SUCCESS for record in result[0] - ): - self.async_write_ha_state() + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._attribute_value} + ) + self.async_write_ha_state() @CONFIG_DIAGNOSTIC_MATCH( diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 9f999bd52fa..cf868ef8b7b 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -416,15 +416,12 @@ class Thermostat(ZhaEntity, ClimateEntity): if self.preset_mode not in ( preset_mode, PRESET_NONE, - ) and not await self.async_preset_handler(self.preset_mode, enable=False): - self.debug("Couldn't turn off '%s' preset", self.preset_mode) - return - - if preset_mode != PRESET_NONE and not await self.async_preset_handler( - preset_mode, enable=True ): - self.debug("Couldn't turn on '%s' preset", preset_mode) - return + await self.async_preset_handler(self.preset_mode, enable=False) + + if preset_mode != PRESET_NONE: + await self.async_preset_handler(preset_mode, enable=True) + self._preset = preset_mode self.async_write_ha_state() @@ -438,30 +435,29 @@ class Thermostat(ZhaEntity, ClimateEntity): if hvac_mode is not None: await self.async_set_hvac_mode(hvac_mode) - thrm = self._thrm + is_away = self.preset_mode == PRESET_AWAY + if self.hvac_mode == HVACMode.HEAT_COOL: - success = True if low_temp is not None: - low_temp = int(low_temp * ZCL_TEMP) - success = success and await thrm.async_set_heating_setpoint( - low_temp, self.preset_mode == PRESET_AWAY + await self._thrm.async_set_heating_setpoint( + temperature=int(low_temp * ZCL_TEMP), + is_away=is_away, ) - self.debug("Setting heating %s setpoint: %s", low_temp, success) if high_temp is not None: - high_temp = int(high_temp * ZCL_TEMP) - success = success and await thrm.async_set_cooling_setpoint( - high_temp, self.preset_mode == PRESET_AWAY + await self._thrm.async_set_cooling_setpoint( + temperature=int(high_temp * ZCL_TEMP), + is_away=is_away, ) - self.debug("Setting cooling %s setpoint: %s", low_temp, success) elif temp is not None: - temp = int(temp * ZCL_TEMP) if self.hvac_mode == HVACMode.COOL: - success = await thrm.async_set_cooling_setpoint( - temp, self.preset_mode == PRESET_AWAY + await self._thrm.async_set_cooling_setpoint( + temperature=int(temp * ZCL_TEMP), + is_away=is_away, ) elif self.hvac_mode == HVACMode.HEAT: - success = await thrm.async_set_heating_setpoint( - temp, self.preset_mode == PRESET_AWAY + await self._thrm.async_set_heating_setpoint( + temperature=int(temp * ZCL_TEMP), + is_away=is_away, ) else: self.debug("Not setting temperature for '%s' mode", self.hvac_mode) @@ -470,14 +466,13 @@ class Thermostat(ZhaEntity, ClimateEntity): self.debug("incorrect %s setting for '%s' mode", kwargs, self.hvac_mode) return - if success: - self.async_write_ha_state() + self.async_write_ha_state() - async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode via handler.""" handler = getattr(self, f"async_preset_handler_{preset}") - return await handler(enable) + await handler(enable) @MULTI_MATCH( @@ -529,7 +524,7 @@ class SinopeTechnologiesThermostat(Thermostat): self.debug("Updating time: %s", secs_2k) self._manufacturer_ch.cluster.create_catching_task( - self._manufacturer_ch.cluster.write_attributes( + self._manufacturer_ch.write_attributes_safe( {"secs_since_2k": secs_2k}, manufacturer=self.manufacturer ) ) @@ -544,16 +539,13 @@ class SinopeTechnologiesThermostat(Thermostat): ) self._async_update_time() - async def async_preset_handler_away(self, is_away: bool = False) -> bool: + async def async_preset_handler_away(self, is_away: bool = False) -> None: """Set occupancy.""" mfg_code = self._zha_device.manufacturer_code - res = await self._thrm.write_attributes( + await self._thrm.write_attributes_safe( {"set_occupancy": 0 if is_away else 1}, manufacturer=mfg_code ) - self.debug("set occupancy to %s. Status: %s", 0 if is_away else 1, res) - return res - @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, @@ -635,40 +627,38 @@ class MoesThermostat(Thermostat): self._preset = PRESET_COMPLEX await super().async_attribute_updated(record) - async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" mfg_code = self._zha_device.manufacturer_code if not enable: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 2}, manufacturer=mfg_code ) if preset == PRESET_AWAY: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 0}, manufacturer=mfg_code ) if preset == PRESET_SCHEDULE: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 1}, manufacturer=mfg_code ) if preset == PRESET_COMFORT: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 3}, manufacturer=mfg_code ) if preset == PRESET_ECO: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 4}, manufacturer=mfg_code ) if preset == PRESET_BOOST: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 5}, manufacturer=mfg_code ) if preset == PRESET_COMPLEX: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 6}, manufacturer=mfg_code ) - return False - @STRICT_MATCH( cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, @@ -714,36 +704,34 @@ class BecaThermostat(Thermostat): self._preset = PRESET_TEMP_MANUAL await super().async_attribute_updated(record) - async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" mfg_code = self._zha_device.manufacturer_code if not enable: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 2}, manufacturer=mfg_code ) if preset == PRESET_AWAY: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 0}, manufacturer=mfg_code ) if preset == PRESET_SCHEDULE: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 1}, manufacturer=mfg_code ) if preset == PRESET_ECO: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 4}, manufacturer=mfg_code ) if preset == PRESET_BOOST: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 5}, manufacturer=mfg_code ) if preset == PRESET_TEMP_MANUAL: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 7}, manufacturer=mfg_code ) - return False - @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, @@ -809,23 +797,22 @@ class ZONNSMARTThermostat(Thermostat): self._preset = self.PRESET_FROST await super().async_attribute_updated(record) - async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" mfg_code = self._zha_device.manufacturer_code if not enable: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 1}, manufacturer=mfg_code ) if preset == PRESET_SCHEDULE: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 0}, manufacturer=mfg_code ) if preset == self.PRESET_HOLIDAY: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 3}, manufacturer=mfg_code ) if preset == self.PRESET_FROST: - return await self._thrm.write_attributes( + return await self._thrm.write_attributes_safe( {"operation_preset": 4}, manufacturer=mfg_code ) - return False diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index ba50839ee44..1b6bbee5159 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -35,6 +35,7 @@ from .core.const import ( from .radio_manager import ( HARDWARE_DISCOVERY_SCHEMA, RECOMMENDED_RADIOS, + ProbeResult, ZhaRadioManager, ) @@ -60,6 +61,8 @@ OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure" UPLOADED_BACKUP_FILE = "uploaded_backup_file" +REPAIR_MY_URL = "https://my.home-assistant.io/redirect/repairs/" + DEFAULT_ZHA_ZEROCONF_PORT = 6638 ESPHOME_API_PORT = 6053 @@ -96,10 +99,12 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: yellow_radio.manufacturer = "Nabu Casa" # Present the multi-PAN addon as a setup option, if it's available - addon_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) try: - addon_info = await addon_manager.async_get_addon_info() + addon_info = await multipan_manager.async_get_addon_info() except (AddonError, KeyError): addon_info = None @@ -185,7 +190,13 @@ class BaseZhaFlow(FlowHandler): port = ports[list_of_ports.index(user_selection)] self._radio_mgr.device_path = port.device - if not await self._radio_mgr.detect_radio_type(): + probe_result = await self._radio_mgr.detect_radio_type() + if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: + return self.async_abort( + reason="wrong_firmware_installed", + description_placeholders={"repair_url": REPAIR_MY_URL}, + ) + if probe_result == ProbeResult.PROBING_FAILED: # Did not autodetect anything, proceed to manual selection return await self.async_step_manual_pick_radio_type() @@ -528,10 +539,17 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN # config flow logic that interacts with hardware. if user_input is not None or not onboarding.async_is_onboarded(self.hass): # Probe the radio type if we don't have one yet - if ( - self._radio_mgr.radio_type is None - and not await self._radio_mgr.detect_radio_type() - ): + if self._radio_mgr.radio_type is None: + probe_result = await self._radio_mgr.detect_radio_type() + else: + probe_result = ProbeResult.RADIO_TYPE_DETECTED + + if probe_result == ProbeResult.WRONG_FIRMWARE_INSTALLED: + return self.async_abort( + reason="wrong_firmware_installed", + description_placeholders={"repair_url": REPAIR_MY_URL}, + ) + if probe_result == ProbeResult.PROBING_FAILED: # This path probably will not happen now that we have # more precise USB matching unless there is a problem # with the device diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 6c05ce2fe4f..2b78c90aa19 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine, Iterator +import contextlib from enum import Enum import functools import logging @@ -48,6 +49,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3) +UNPROXIED_CLUSTER_METHODS = {"general_command"} _P = ParamSpec("_P") @@ -55,24 +57,31 @@ _FuncType = Callable[_P, Awaitable[Any]] _ReturnFuncType = Callable[_P, Coroutine[Any, Any, Any]] +@contextlib.contextmanager +def wrap_zigpy_exceptions() -> Iterator[None]: + """Wrap zigpy exceptions in `HomeAssistantError` exceptions.""" + try: + yield + except asyncio.TimeoutError as exc: + raise HomeAssistantError( + "Failed to send request: device did not respond" + ) from exc + except zigpy.exceptions.ZigbeeException as exc: + message = "Failed to send request" + + if str(exc): + message = f"{message}: {exc}" + + raise HomeAssistantError(message) from exc + + def retry_request(func: _FuncType[_P]) -> _ReturnFuncType[_P]: """Send a request with retries and wrap expected zigpy exceptions.""" @functools.wraps(func) async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any: - try: + with wrap_zigpy_exceptions(): return await RETRYABLE_REQUEST_DECORATOR(func)(*args, **kwargs) - except asyncio.TimeoutError as exc: - raise HomeAssistantError( - "Failed to send request: device did not respond" - ) from exc - except zigpy.exceptions.ZigbeeException as exc: - message = "Failed to send request" - - if str(exc): - message = f"{message}: {exc}" - - raise HomeAssistantError(message) from exc return wrapper @@ -501,6 +510,26 @@ class ClusterHandler(LogMixin): get_attributes = functools.partialmethod(_get_attributes, False) + async def write_attributes_safe( + self, attributes: dict[str, Any], manufacturer: int | None = None + ) -> None: + """Wrap `write_attributes` to throw an exception on attribute write failure.""" + + res = await self.write_attributes(attributes, manufacturer=manufacturer) + + for record in res[0]: + if record.status != Status.SUCCESS: + try: + name = self.cluster.attributes[record.attrid].name + value = attributes.get(name, "unknown") + except KeyError: + name = f"0x{record.attrid:04x}" + value = "unknown" + + raise HomeAssistantError( + f"Failed to write attribute {name}={value}: {record.status}", + ) + def log(self, level, msg, *args, **kwargs): """Log a message.""" msg = f"[%s:%s]: {msg}" @@ -509,11 +538,16 @@ class ClusterHandler(LogMixin): def __getattr__(self, name): """Get attribute or a decorated cluster command.""" - if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)): + if ( + hasattr(self._cluster, name) + and callable(getattr(self._cluster, name)) + and name not in UNPROXIED_CLUSTER_METHODS + ): command = getattr(self._cluster, name) - command.__name__ = name + wrapped_command = retry_request(command) + wrapped_command.__name__ = name - return retry_request(command) + return wrapped_command return self.__getattribute__(name) diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 622c9e4340e..6ca4e420d5f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -1,7 +1,6 @@ """General cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -import asyncio from collections.abc import Coroutine from typing import TYPE_CHECKING, Any @@ -12,6 +11,7 @@ from zigpy.zcl.clusters import general from zigpy.zcl.foundation import Status from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_call_later from .. import registries @@ -111,18 +111,9 @@ class AnalogOutput(ClusterHandler): """Return cached value of application_type.""" return self.cluster.get("application_type") - async def async_set_present_value(self, value: float) -> bool: + async def async_set_present_value(self, value: float) -> None: """Update present_value.""" - try: - res = await self.cluster.write_attributes({"present_value": value}) - except zigpy.exceptions.ZigbeeException as ex: - self.error("Could not set value: %s", ex) - return False - if not isinstance(res, Exception) and all( - record.status == Status.SUCCESS for record in res[0] - ): - return True - return False + await self.write_attributes_safe({"present_value": value}) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(general.AnalogValue.cluster_id) @@ -164,9 +155,7 @@ class BasicClusterHandler(ClusterHandler): """Initialize Basic cluster handler.""" super().__init__(cluster, endpoint) if is_hue_motion_sensor(self) and self.cluster.endpoint.endpoint_id == 2: - self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name - self.ZCL_INIT_ATTRS.copy() - ) + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["trigger_indicator"] = True elif ( self.cluster.endpoint.manufacturer == "TexasInstruments" @@ -373,7 +362,7 @@ class OnOffClusterHandler(ClusterHandler): except KeyError: return - self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["backlight_mode"] = True self.ZCL_INIT_ATTRS["power_on_state"] = True @@ -394,21 +383,19 @@ class OnOffClusterHandler(ClusterHandler): """Return cached value of on/off attribute.""" return self.cluster.get("on_off") - async def turn_on(self) -> bool: + async def turn_on(self) -> None: """Turn the on off cluster on.""" result = await self.on() - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: - return False + 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) - return True - async def turn_off(self) -> bool: + async def turn_off(self) -> None: """Turn the on off cluster off.""" result = await self.off() - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: - return False + 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) - return True @callback def cluster_command(self, tsn, command_id, args): @@ -510,13 +497,7 @@ class PollControl(ClusterHandler): async def async_configure_cluster_handler_specific(self) -> None: """Configure cluster handler: set check-in interval.""" - try: - res = await self.cluster.write_attributes( - {"checkin_interval": self.CHECKIN_INTERVAL} - ) - self.debug("%ss check-in interval set: %s", self.CHECKIN_INTERVAL / 4, res) - except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: - self.debug("Couldn't set check-in interval: %s", ex) + await self.write_attributes_safe({"checkin_interval": self.CHECKIN_INTERVAL}) @callback def cluster_command( diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index cbc56f5acc5..15050ce67b1 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -8,9 +8,7 @@ from __future__ import annotations from collections import namedtuple from typing import Any -from zigpy.exceptions import ZigbeeException from zigpy.zcl.clusters import hvac -from zigpy.zcl.foundation import Status from homeassistant.core import callback @@ -55,12 +53,7 @@ class FanClusterHandler(ClusterHandler): async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" - - try: - await self.cluster.write_attributes({"fan_mode": value}) - except ZigbeeException as ex: - self.error("Could not set speed: %s", ex) - return + await self.write_attributes_safe({"fan_mode": value}) async def async_update(self) -> None: """Retrieve latest state.""" @@ -247,71 +240,32 @@ class ThermostatClusterHandler(ClusterHandler): async def async_set_operation_mode(self, mode) -> bool: """Set Operation mode.""" - if not await self.write_attributes({"system_mode": mode}): - self.debug("couldn't set '%s' operation mode", mode) - return False - - self.debug("set system to %s", mode) + await self.write_attributes_safe({"system_mode": mode}) return True async def async_set_heating_setpoint( self, temperature: int, is_away: bool = False ) -> bool: """Set heating setpoint.""" - if is_away: - data = {"unoccupied_heating_setpoint": temperature} - else: - data = {"occupied_heating_setpoint": temperature} - if not await self.write_attributes(data): - self.debug("couldn't set heating setpoint") - return False - + attr = "unoccupied_heating_setpoint" if is_away else "occupied_heating_setpoint" + await self.write_attributes_safe({attr: temperature}) return True async def async_set_cooling_setpoint( self, temperature: int, is_away: bool = False ) -> bool: """Set cooling setpoint.""" - if is_away: - data = {"unoccupied_cooling_setpoint": temperature} - else: - data = {"occupied_cooling_setpoint": temperature} - if not await self.write_attributes(data): - self.debug("couldn't set cooling setpoint") - return False - self.debug("set cooling setpoint to %s", temperature) + attr = "unoccupied_cooling_setpoint" if is_away else "occupied_cooling_setpoint" + await self.write_attributes_safe({attr: temperature}) return True async def get_occupancy(self) -> bool | None: """Get unreportable occupancy attribute.""" - try: - res, fail = await self.cluster.read_attributes(["occupancy"]) - self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) - if "occupancy" not in res: - return None - return bool(self.occupancy) - except ZigbeeException as ex: - self.debug("Couldn't read 'occupancy' attribute: %s", ex) + res, fail = await self.read_attributes(["occupancy"]) + self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) + if "occupancy" not in res: return None - - async def write_attributes(self, data, **kwargs): - """Write attributes helper.""" - try: - res = await self.cluster.write_attributes(data, **kwargs) - except ZigbeeException as exc: - self.debug("couldn't write %s: %s", data, exc) - return False - - self.debug("wrote %s attrs, Status: %s", data, res) - return self.check_result(res) - - @staticmethod - def check_result(res: list) -> bool: - """Normalize the result.""" - if isinstance(res, Exception): - return False - - return all(record.status == Status.SUCCESS for record in res[0]) + return bool(self.occupancy) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(hvac.UserInterface.cluster_id) diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index e46031cce14..f2e5dafa099 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -5,7 +5,6 @@ import logging from typing import TYPE_CHECKING, Any from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType -from zigpy.exceptions import ZigbeeException import zigpy.zcl from homeassistant.core import callback @@ -92,7 +91,7 @@ class TuyaClusterHandler(ClusterHandler): "_TZE200_k6jhsr0q", "_TZE200_9mahtqtg", ): - self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS = { "backlight_mode": True, "power_on_state": True, } @@ -109,7 +108,7 @@ class OppleRemote(ClusterHandler): """Initialize Opple cluster handler.""" super().__init__(cluster, endpoint) if self.cluster.endpoint.model == "lumi.motion.ac02": - self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + self.ZCL_INIT_ATTRS = { "detection_interval": True, "motion_sensitivity": True, "trigger_indicator": True, @@ -351,12 +350,7 @@ class IkeaAirPurifierClusterHandler(ClusterHandler): async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" - - try: - await self.cluster.write_attributes({"fan_mode": value}) - except ZigbeeException as ex: - self.error("Could not set speed: %s", ex) - return + await self.write_attributes_safe({"fan_mode": value}) async def async_update(self) -> None: """Retrieve latest state.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/measurement.py b/homeassistant/components/zha/core/cluster_handlers/measurement.py index beeb6296e32..bd483920842 100644 --- a/homeassistant/components/zha/core/cluster_handlers/measurement.py +++ b/homeassistant/components/zha/core/cluster_handlers/measurement.py @@ -67,9 +67,7 @@ class OccupancySensing(ClusterHandler): """Initialize Occupancy cluster handler.""" super().__init__(cluster, endpoint) if is_hue_motion_sensor(self): - self.ZCL_INIT_ATTRS = ( # pylint: disable=invalid-name - self.ZCL_INIT_ATTRS.copy() - ) + self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() self.ZCL_INIT_ATTRS["sensitivity"] = True diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index 28e2d863662..f31830f0bd8 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -8,12 +8,12 @@ from __future__ import annotations from collections.abc import Callable from typing import TYPE_CHECKING, Any -from zigpy.exceptions import ZigbeeException import zigpy.zcl from zigpy.zcl.clusters import security from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from .. import registries from ..const import ( @@ -350,8 +350,11 @@ class IASZoneClusterHandler(ClusterHandler): self.debug("Updated alarm state: %s", zone_status) elif command_id == 1: self.debug("Enroll requested") - res = self._cluster.enroll_response(0, 0) - self._cluster.create_catching_task(res) + self._cluster.create_catching_task( + self.enroll_response( + enroll_response_code=IasZone.EnrollResponse.Success, zone_id=0 + ) + ) async def async_configure(self): """Configure IAS device.""" @@ -366,14 +369,14 @@ class IASZoneClusterHandler(ClusterHandler): ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: - res = await self._cluster.write_attributes({"cie_addr": ieee}) + res = await self.write_attributes_safe({"cie_addr": ieee}) self.debug( "wrote cie_addr: %s to '%s' cluster: %s", str(ieee), self._cluster.ep_attribute, res[0], ) - except ZigbeeException as ex: + except HomeAssistantError as ex: self.debug( "Failed to write cie_addr: %s to '%s' cluster: %s", str(ieee), @@ -382,7 +385,11 @@ class IASZoneClusterHandler(ClusterHandler): ) self.debug("Sending pro-active IAS enroll response") - self._cluster.create_catching_task(self._cluster.enroll_response(0, 0)) + self._cluster.create_catching_task( + self.enroll_response( + enroll_response_code=IasZone.EnrollResponse.Success, zone_id=0 + ) + ) self._status = ClusterHandlerStatus.CONFIGURED self.debug("finished IASZoneClusterHandler configuration") diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c90c78243d1..63b59e9d8d4 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,7 +7,7 @@ import logging import bellows.zigbee.application import voluptuous as vol import zigpy.application -from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import +from zigpy.config import CONF_DEVICE_PATH # noqa: F401 import zigpy.types as t import zigpy_deconz.zigbee.application import zigpy_xbee.zigbee.application @@ -187,7 +187,6 @@ DATA_ZHA_CONFIG = "config" DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" DATA_ZHA_GATEWAY = "zha_gateway" -DATA_ZHA_SHUTDOWN_TASK = "zha_shutdown_task" DEBUG_COMP_BELLOWS = "bellows" DEBUG_COMP_ZHA = "homeassistant.components.zha" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 0ca1c136271..92b68bdb159 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType -from .. import ( # noqa: F401 pylint: disable=unused-import, +from .. import ( # noqa: F401 alarm_control_panel, binary_sensor, button, @@ -35,7 +35,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import, from . import const as zha_const, registries as zha_regs # importing cluster handlers updates registries -from .cluster_handlers import ( # noqa: F401 pylint: disable=unused-import, +from .cluster_handlers import ( # noqa: F401 ClusterHandler, closures, general, diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index 53a3fb883ef..bdef5ac46af 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -27,7 +27,7 @@ ATTR_IN_CLUSTERS: Final[str] = "input_clusters" ATTR_OUT_CLUSTERS: Final[str] = "output_clusters" _LOGGER = logging.getLogger(__name__) -CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) class Endpoint: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 02c16930d53..353bc6904d7 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -27,8 +27,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType from . import discovery @@ -148,7 +148,12 @@ class ZHAGateway: self._log_relay_handler = LogRelayHandler(hass, self) self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] - self.initialized: bool = False + + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" @@ -192,19 +197,33 @@ class ZHAGateway: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - app_controller_cls, app_config = self.get_application_controller_data() + self.application_controller = await app_controller_cls.new( + config=app_config, + auto_form=False, + start_radio=False, + ) + + self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self + + self.async_load_devices() + + # Groups are attached to the coordinator device so we need to load it early + coordinator = self._find_coordinator_device() + loaded_groups = False + + # We can only load groups early if the coordinator's model info has been stored + # in the zigpy database + if coordinator.model is not None: + self.coordinator_zha_device = self._async_get_or_create_device( + coordinator, restored=True + ) + self.async_load_groups() + loaded_groups = True for attempt in range(STARTUP_RETRIES): try: - self.application_controller = await app_controller_cls.new( - app_config, auto_form=True, start_radio=True - ) + await self.application_controller.startup(auto_form=True) except zigpy.exceptions.TransientConnectionError as exc: raise ConfigEntryNotReady from exc except Exception as exc: # pylint: disable=broad-except @@ -223,21 +242,33 @@ class ZHAGateway: else: break + self.coordinator_zha_device = self._async_get_or_create_device( + self._find_coordinator_device(), restored=True + ) + self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) + + # If ZHA groups could not load early, we can safely load them now + if not loaded_groups: + self.async_load_groups() + self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) - self.async_load_devices() - self.async_load_groups() - self.initialized = True + + def _find_coordinator_device(self) -> zigpy.device.Device: + if last_backup := self.application_controller.backups.most_recent_backup(): + zigpy_coordinator = self.application_controller.get_device( + ieee=last_backup.node_info.ieee + ) + else: + zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) + + return zigpy_coordinator @callback def async_load_devices(self) -> None: """Restore ZHA devices from zigpy application state.""" for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) - if zha_device.ieee == self.coordinator_ieee: - self.coordinator_zha_device = zha_device delta_msg = "not known" if zha_device.last_seen is not None: delta = round(time.time() - zha_device.last_seen) @@ -802,7 +833,6 @@ class LogRelayHandler(logging.Handler): hass_path: str = HOMEASSISTANT_PATH[0] config_dir = self.hass.config.config_dir - assert config_dir is not None paths_re = re.compile( r"(?:{})/(.*)".format( "|".join([re.escape(x) for x in (hass_path, config_dir)]) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index ac7c15d3ecd..7b0d062738b 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -27,7 +27,6 @@ import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, State, callback -from homeassistant.exceptions import IntegrationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( @@ -246,11 +245,8 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: _LOGGER.error("Device id `%s` not found in registry", device_id) raise KeyError(f"Device id `{device_id}` not found in registry.") zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - if not zha_gateway.initialized: - _LOGGER.error("Attempting to get a ZHA device when ZHA is not initialized") - raise IntegrationError("ZHA is not initialized yet") try: - ieee_address = list(list(registry_device.identifiers)[0])[1] + ieee_address = list(registry_device.identifiers)[0][1] ieee = zigpy.types.EUI64.convert(ieee_address) except (IndexError, ValueError) as ex: _LOGGER.error( diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 4d76ea27897..0d7062173ca 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -23,6 +23,7 @@ from homeassistant.const import ( 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 @@ -139,30 +140,34 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" res = await self._cover_cluster_handler.up_open() - if not isinstance(res, Exception) and res[1] is Status.SUCCESS: - self.async_update_state(STATE_OPENING) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover: {res[1]}") + self.async_update_state(STATE_OPENING) async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._cover_cluster_handler.down_close() - if not isinstance(res, Exception) and res[1] is Status.SUCCESS: - self.async_update_state(STATE_CLOSING) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover: {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) - if not isinstance(res, Exception) and res[1] is Status.SUCCESS: - self.async_update_state( - STATE_CLOSING if new_pos < self._current_position else STATE_OPENING - ) + 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 + ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" res = await self._cover_cluster_handler.stop() - if not isinstance(res, Exception) and res[1] is Status.SUCCESS: - self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED - self.async_write_ha_state() + 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.async_write_ha_state() async def async_update(self) -> None: """Attempt to retrieve the open/close state of the cover.""" @@ -265,9 +270,8 @@ class Shade(ZhaEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" res = await self._on_off_cluster_handler.on() - if isinstance(res, Exception) or res[1] != Status.SUCCESS: - self.debug("couldn't open cover: %s", res) - return + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover: {res[1]}") self._is_open = True self.async_write_ha_state() @@ -275,9 +279,8 @@ class Shade(ZhaEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._on_off_cluster_handler.off() - if isinstance(res, Exception) or res[1] != Status.SUCCESS: - self.debug("couldn't open cover: %s", res) - return + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover: {res[1]}") self._is_open = False self.async_write_ha_state() @@ -289,9 +292,8 @@ class Shade(ZhaEntity, CoverEntity): new_pos * 255 / 100, 1 ) - if isinstance(res, Exception) or res[1] != Status.SUCCESS: - self.debug("couldn't set cover's position: %s", res) - return + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to set cover position: {res[1]}") self._position = new_pos self.async_write_ha_state() @@ -299,9 +301,8 @@ class Shade(ZhaEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" res = await self._level_cluster_handler.stop() - if isinstance(res, Exception) or res[1] != Status.SUCCESS: - self.debug("couldn't stop cover: %s", res) - return + if res[1] != Status.SUCCESS: + raise HomeAssistantError(f"Failed to stop cover: {res[1]}") @MULTI_MATCH( diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 885cd788f70..bda346624dd 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -8,8 +8,8 @@ from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery @@ -111,7 +111,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): return self._battery_level @property # type: ignore[misc] - def device_info( # pylint: disable=overridden-final-method + def device_info( self, ) -> DeviceInfo: """Return device info.""" diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 7f34629400f..f2b16a37834 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_NAME from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import entity from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -79,11 +79,11 @@ class BaseZhaEntity(LogMixin, entity.Entity): return self._extra_state_attributes @property - def device_info(self) -> entity.DeviceInfo: + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" zha_device_info = self._zha_device.device_info ieee = zha_device_info["ieee"] - return entity.DeviceInfo( + return DeviceInfo( connections={(CONNECTION_ZIGBEE, ieee)}, identifiers={(DOMAIN, ieee)}, manufacturer=zha_device_info[ATTR_MANUFACTURER], diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 82725accfa4..a24272c9a7a 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -6,7 +6,6 @@ import functools import math from typing import Any -from zigpy.exceptions import ZigbeeException from zigpy.zcl.clusters import hvac from homeassistant.components.fan import ( @@ -28,6 +27,7 @@ from homeassistant.util.percentage import ( ) from .core import discovery +from .core.cluster_handlers import wrap_zigpy_exceptions from .core.const import ( CLUSTER_HANDLER_FAN, DATA_ZHA, @@ -207,10 +207,10 @@ class FanGroup(BaseFan, ZhaGroupEntity): async def _async_set_fan_mode(self, fan_mode: int) -> None: """Set the fan mode for the group.""" - try: + + with wrap_zigpy_exceptions(): await self._fan_cluster_handler.write_attributes({"fan_mode": fan_mode}) - except ZigbeeException as ex: - self.error("Could not set fan mode: %s", ex) + self.async_set_state(0, "fan_mode", fan_mode) async def async_update(self) -> None: diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 73955614c07..2ec42431498 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -298,7 +298,7 @@ class BaseLight(LogMixin, light.LightEntity): transition_time=int(10 * self._DEFAULT_MIN_TRANSITION_TIME), ) t_log["move_to_level_with_on_off"] = result - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: # First 'move to level' call failed, so if the transitioning delay # isn't running from a previous call, # the flag can be unset immediately @@ -338,7 +338,7 @@ class BaseLight(LogMixin, light.LightEntity): transition_time=int(10 * duration), ) t_log["move_to_level_with_on_off"] = result - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: # First 'move to level' call failed, so if the transitioning delay # isn't running from a previous call, the flag can be unset immediately if set_transition_flag and not self._transition_listener: @@ -359,7 +359,7 @@ class BaseLight(LogMixin, light.LightEntity): # if brightness is not 0. result = await self._on_off_cluster_handler.on() t_log["on_off"] = result - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: # 'On' call failed, but as brightness may still transition # (for FORCE_ON lights), we start the timer to unset the flag after # the transition_time if necessary. @@ -391,7 +391,7 @@ class BaseLight(LogMixin, light.LightEntity): level=level, transition_time=int(10 * duration) ) t_log["move_to_level_if_color"] = result - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return self._attr_state = bool(level) @@ -474,7 +474,7 @@ class BaseLight(LogMixin, light.LightEntity): if self._zha_config_enable_light_transitioning_flag: self.async_transition_start_timer(transition_time) self.debug("turned off: %s", result) - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: return self._attr_state = False @@ -514,7 +514,7 @@ class BaseLight(LogMixin, light.LightEntity): transition_time=int(10 * transition_time), ) t_log["move_to_color_temp"] = result - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: return False self._attr_color_mode = ColorMode.COLOR_TEMP self._attr_color_temp = temperature @@ -539,7 +539,7 @@ class BaseLight(LogMixin, light.LightEntity): transition_time=int(10 * transition_time), ) t_log["move_to_hue_and_saturation"] = result - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: return False self._attr_color_mode = ColorMode.HS self._attr_hs_color = hs_color @@ -554,7 +554,7 @@ class BaseLight(LogMixin, light.LightEntity): transition_time=int(10 * transition_time), ) t_log["move_to_color"] = result - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: return False self._attr_color_mode = ColorMode.XY self._attr_xy_color = xy_color @@ -1112,13 +1112,13 @@ class LightGroup(BaseLight, ZhaGroupEntity): super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) group = self.zha_device.gateway.get_group(self._group_id) - self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True # pylint: disable=invalid-name + self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True for member in group.members: # Ensure we do not send group commands that violate the minimum transition # time of any members. if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS: - self._DEFAULT_MIN_TRANSITION_TIME = ( # pylint: disable=invalid-name + self._DEFAULT_MIN_TRANSITION_TIME = ( MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME ) diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 2f6bce0b20e..1e68e95c881 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -132,7 +132,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" result = await self._doorlock_cluster_handler.lock_door() - if isinstance(result, Exception) or result[0] is not Status.SUCCESS: + if result[0] is not Status.SUCCESS: self.error("Error with lock_door: %s", result) return self.async_write_ha_state() @@ -140,7 +140,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" result = await self._doorlock_cluster_handler.unlock_door() - if isinstance(result, Exception) or result[0] is not Status.SUCCESS: + if result[0] is not Status.SUCCESS: self.error("Error with unlock_door: %s", result) return self.async_write_ha_state() diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 29fed3a3c9f..7352487a318 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -17,18 +17,20 @@ "zigpy_deconz", "zigpy_xbee", "zigpy_zigate", - "zigpy_znp" + "zigpy_znp", + "universal_silabs_flasher" ], "requirements": [ - "bellows==0.35.9", + "bellows==0.36.2", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.102", + "zha-quirks==0.0.103", "zigpy-deconz==0.21.0", - "zigpy==0.56.4", + "zigpy==0.57.1", "zigpy-xbee==0.18.1", "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.4" + "zigpy-znp==0.11.4", + "universal-silabs-flasher==0.0.13" ], "usb": [ { @@ -111,6 +113,10 @@ "type": "_zigstar_gw._tcp.local.", "name": "*zigstar*" }, + { + "type": "_uzg-01._tcp.local.", + "name": "uzg-01*" + }, { "type": "_slzb-06._tcp.local.", "name": "slzb-06*" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 807a5e73d00..c12060eb2a8 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -5,9 +5,6 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self -import zigpy.exceptions -from zigpy.zcl.foundation import Status - from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature @@ -362,9 +359,8 @@ class ZhaNumber(ZhaEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" - num_value = float(value) - if await self._analog_output_cluster_handler.async_set_present_value(num_value): - self.async_write_ha_state() + await self._analog_output_cluster_handler.async_set_present_value(float(value)) + self.async_write_ha_state() async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" @@ -434,17 +430,10 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" - try: - res = await self._cluster_handler.cluster.write_attributes( - {self._zcl_attribute: int(value / self._attr_multiplier)} - ) - except zigpy.exceptions.ZigbeeException as ex: - self.error("Could not set value: %s", ex) - return - if not isinstance(res, Exception) and all( - record.status == Status.SUCCESS for record in res[0] - ): - self.async_write_ha_state() + await self._cluster_handler.write_attributes_safe( + {self._zcl_attribute: int(value / self._attr_multiplier)} + ) + self.async_write_ha_state() async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 29214083d27..751fea99847 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -5,10 +5,12 @@ import asyncio import contextlib from contextlib import suppress import copy +import enum import logging import os from typing import Any +from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups @@ -19,6 +21,7 @@ from homeassistant import config_entries from homeassistant.components import usb from homeassistant.core import HomeAssistant +from . import repairs from .core.const import ( CONF_DATABASE, CONF_RADIO_TYPE, @@ -47,7 +50,9 @@ RECOMMENDED_RADIOS = ( ) CONNECT_DELAY_S = 1.0 +RETRY_DELAY_S = 1.0 +BACKUP_RETRIES = 5 MIGRATION_RETRIES = 100 HARDWARE_DISCOVERY_SCHEMA = vol.Schema( @@ -73,6 +78,14 @@ HARDWARE_MIGRATION_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) +class ProbeResult(enum.StrEnum): + """Radio firmware probing result.""" + + RADIO_TYPE_DETECTED = "radio_type_detected" + WRONG_FIRMWARE_INSTALLED = "wrong_firmware_installed" + PROBING_FAILED = "probing_failed" + + def _allow_overwrite_ezsp_ieee( backup: zigpy.backups.NetworkBackup, ) -> zigpy.backups.NetworkBackup: @@ -134,6 +147,7 @@ class ZhaRadioManager: app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings app_config[CONF_NWK_BACKUP_ENABLED] = False + app_config[CONF_USE_THREAD] = False app_config = self.radio_type.controller.SCHEMA(app_config) app = await self.radio_type.controller.new( @@ -167,8 +181,10 @@ class ZhaRadioManager: return RadioType[radio_type] - async def detect_radio_type(self) -> bool: + async def detect_radio_type(self) -> ProbeResult: """Probe all radio types on the current port.""" + assert self.device_path is not None + for radio in AUTOPROBE_RADIOS: _LOGGER.debug("Attempting to probe radio type %s", radio) @@ -187,9 +203,14 @@ class ZhaRadioManager: self.radio_type = radio self.device_settings = dev_config - return True + repairs.async_delete_blocking_issues(self.hass) + return ProbeResult.RADIO_TYPE_DETECTED - return False + with suppress(repairs.AlreadyRunningEZSP): + if await repairs.warn_on_wrong_silabs_firmware(self.hass, self.device_path): + return ProbeResult.WRONG_FIRMWARE_INSTALLED + + return ProbeResult.PROBING_FAILED async def async_load_network_settings( self, *, create_backup: bool = False @@ -341,7 +362,24 @@ class ZhaMultiPANMigrationHelper: old_radio_mgr.device_path = config_entry_data[CONF_DEVICE][CONF_DEVICE_PATH] old_radio_mgr.device_settings = config_entry_data[CONF_DEVICE] old_radio_mgr.radio_type = RadioType[config_entry_data[CONF_RADIO_TYPE]] - backup = await old_radio_mgr.async_load_network_settings(create_backup=True) + + for retry in range(BACKUP_RETRIES): + try: + backup = await old_radio_mgr.async_load_network_settings( + create_backup=True + ) + break + except OSError as err: + if retry >= BACKUP_RETRIES - 1: + raise + + _LOGGER.debug( + "Failed to create backup %r, retrying in %s seconds", + err, + RETRY_DELAY_S, + ) + + await asyncio.sleep(RETRY_DELAY_S) # Then configure the radio manager for the new radio to use the new settings self._radio_mgr.chosen_backup = backup @@ -381,10 +419,10 @@ class ZhaMultiPANMigrationHelper: _LOGGER.debug( "Failed to restore backup %r, retrying in %s seconds", err, - CONNECT_DELAY_S, + RETRY_DELAY_S, ) - await asyncio.sleep(CONNECT_DELAY_S) + await asyncio.sleep(RETRY_DELAY_S) _LOGGER.debug("Restored backup after %s retries", retry) diff --git a/homeassistant/components/zha/repairs.py b/homeassistant/components/zha/repairs.py new file mode 100644 index 00000000000..ac523f37aa0 --- /dev/null +++ b/homeassistant/components/zha/repairs.py @@ -0,0 +1,126 @@ +"""ZHA repairs for common environmental and device problems.""" +from __future__ import annotations + +import enum +import logging + +from universal_silabs_flasher.const import ApplicationType +from universal_silabs_flasher.flasher import Flasher + +from homeassistant.components.homeassistant_sky_connect import ( + hardware as skyconnect_hardware, +) +from homeassistant.components.homeassistant_yellow import ( + RADIO_DEVICE as YELLOW_RADIO_DEVICE, + hardware as yellow_hardware, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir + +from .core.const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AlreadyRunningEZSP(Exception): + """The device is already running EZSP firmware.""" + + +class HardwareType(enum.StrEnum): + """Detected Zigbee hardware type.""" + + SKYCONNECT = "skyconnect" + YELLOW = "yellow" + OTHER = "other" + + +DISABLE_MULTIPAN_URL = { + HardwareType.YELLOW: ( + "https://yellow.home-assistant.io/guides/disable-multiprotocol/#flash-the-silicon-labs-radio-firmware" + ), + HardwareType.SKYCONNECT: ( + "https://skyconnect.home-assistant.io/procedures/disable-multiprotocol/#step-flash-the-silicon-labs-radio-firmware" + ), + HardwareType.OTHER: None, +} + +ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED = "wrong_silabs_firmware_installed" + + +def _detect_radio_hardware(hass: HomeAssistant, device: str) -> HardwareType: + """Identify the radio hardware with the given serial port.""" + try: + yellow_hardware.async_info(hass) + except HomeAssistantError: + pass + else: + if device == YELLOW_RADIO_DEVICE: + return HardwareType.YELLOW + + try: + info = skyconnect_hardware.async_info(hass) + except HomeAssistantError: + pass + else: + for hardware_info in info: + for entry_id in hardware_info.config_entries or []: + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is not None and entry.data["device"] == device: + return HardwareType.SKYCONNECT + + return HardwareType.OTHER + + +async def probe_silabs_firmware_type(device: str) -> ApplicationType | None: + """Probe the running firmware on a Silabs device.""" + flasher = Flasher(device=device) + + try: + await flasher.probe_app_type() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Failed to probe application type", exc_info=True) + + return flasher.app_type + + +async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> bool: + """Create a repair issue if the wrong type of SiLabs firmware is detected.""" + # Only consider actual serial ports + if device.startswith("socket://"): + return False + + app_type = await probe_silabs_firmware_type(device) + + if app_type is None: + # Failed to probe, we can't tell if the wrong firmware is installed + return False + + if app_type == ApplicationType.EZSP: + # If connecting fails but we somehow probe EZSP (e.g. stuck in bootloader), + # reconnect, it should work + raise AlreadyRunningEZSP() + + hardware_type = _detect_radio_hardware(hass, device) + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + is_fixable=False, + is_persistent=True, + learn_more_url=DISABLE_MULTIPAN_URL[hardware_type], + severity=ir.IssueSeverity.ERROR, + translation_key=( + ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED + + ("_nabucasa" if hardware_type != HardwareType.OTHER else "_other") + ), + translation_placeholders={"firmware_type": app_type.name}, + ) + + return True + + +def async_delete_blocking_issues(hass: HomeAssistant) -> None: + """Delete repair issues that should disappear on a successful startup.""" + ir.async_delete_issue(hass, DOMAIN, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index e6f2f6ab482..018f24675e7 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -210,7 +210,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._cluster_handler.cluster.write_attributes( + await self._cluster_handler.write_attributes_safe( {self._select_attr: self._enum[option.replace(" ", "_")]} ) self.async_write_ha_state() diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 49ba46038f9..535733230b9 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations import enum import functools import numbers -import sys from typing import TYPE_CHECKING, Any, Self from zigpy import types @@ -229,7 +228,7 @@ class Battery(Sensor): return cls(unique_id, zha_device, cluster_handlers, **kwargs) @staticmethod - def formatter(value: int) -> int | None: # pylint: disable=arguments-differ + def formatter(value: int) -> int | None: """Return the state of the entity.""" # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ if not isinstance(value, numbers.Number) or value == -1: @@ -485,7 +484,7 @@ class SmartEnergyMetering(Sensor): if self._cluster_handler.device_type is not None: attrs["device_type"] = self._cluster_handler.device_type if (status := self._cluster_handler.status) is not None: - if isinstance(status, enum.IntFlag) and sys.version_info >= (3, 11): + if isinstance(status, enum.IntFlag): attrs["status"] = str( status.name if status.name is not None else status.value ) @@ -968,6 +967,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): _attr_icon = "mdi:timer" _attr_name: str = "Device run time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") @@ -980,6 +980,7 @@ class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): _attr_icon = "mdi:timer" _attr_name: str = "Filter run time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC class AqaraFeedingSource(types.enum8): diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 9731fb0c2d1..87738e821ea 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -62,7 +62,7 @@ }, "maybe_confirm_ezsp_restore": { "title": "Overwrite Radio IEEE Address", - "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", + "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "data": { "overwrite_coordinator_ieee": "Permanently replace the radio IEEE address" } @@ -75,7 +75,8 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "This device is not a zha device", - "usb_probe_failed": "Failed to probe the usb device" + "usb_probe_failed": "Failed to probe the usb device", + "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this." } }, "options": { @@ -83,7 +84,7 @@ "step": { "init": { "title": "Reconfigure ZHA", - "description": "ZHA will be stopped. Do you wish to continue?" + "description": "ZHA will be stopped. Do you wish to continue?" }, "prompt_migrate_or_reconfigure": { "title": "Migrate or re-configure", @@ -95,11 +96,11 @@ }, "intent_migrate": { "title": "[%key:component::zha::options::step::prompt_migrate_or_reconfigure::menu_options::intent_migrate%]", - "description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?" + "description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?" }, "instruct_unplug": { "title": "Unplug your old radio", - "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio." + "description": "Your old radio has been reset. If the hardware is no longer needed, you can now unplug it.\n\nYou can now plug in your new radio." }, "choose_serial_port": { "title": "[%key:component::zha::config::step::choose_serial_port::title%]", @@ -168,7 +169,8 @@ "abort": { "single_instance_allowed": "[%key:component::zha::config::abort::single_instance_allowed%]", "not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]", - "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]" + "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]", + "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]" } }, "config_panel": { @@ -502,5 +504,15 @@ } } } + }, + "issues": { + "wrong_silabs_firmware_installed_nabucasa": { + "title": "Zigbee radio with multiprotocol firmware detected", + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n -. Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." + }, + "wrong_silabs_firmware_installed_other": { + "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). To run your radio exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee radio manufacturer's instructions for how to do this." + } } } diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index f975cc5116d..8707dda629f 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -5,7 +5,6 @@ import functools import logging from typing import TYPE_CHECKING, Any, Self -import zigpy.exceptions from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -85,16 +84,12 @@ class Switch(ZhaEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - result = await self._on_off_cluster_handler.turn_on() - if not result: - return + await self._on_off_cluster_handler.turn_on() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - result = await self._on_off_cluster_handler.turn_off() - if not result: - return + await self._on_off_cluster_handler.turn_off() self.async_write_ha_state() @callback @@ -145,7 +140,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" result = await self._on_off_cluster_handler.on() - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: return self._state = True self.async_write_ha_state() @@ -153,7 +148,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" result = await self._on_off_cluster_handler.off() - if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + if result[1] is not Status.SUCCESS: return self._state = False self.async_write_ha_state() @@ -241,17 +236,10 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" - try: - result = await self._cluster_handler.cluster.write_attributes( - {self._zcl_attribute: not state if self.inverted else state} - ) - except zigpy.exceptions.ZigbeeException as ex: - self.error("Could not set value: %s", ex) - return - if not isinstance(result, Exception) and all( - record.status == Status.SUCCESS for record in result[0] - ): - self.async_write_ha_state() + await self._cluster_handler.write_attributes_safe( + {self._zcl_attribute: not state if self.inverted else state} + ) + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py index d9b306da4dd..2e79f3804ab 100644 --- a/homeassistant/components/zodiac/sensor.py +++ b/homeassistant/components/zodiac/sensor.py @@ -4,8 +4,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, utcnow diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 9412c612ca2..09f93f9b786 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -12,12 +12,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_ZONE, ) -from homeassistant.core import ( - CALLBACK_TYPE, - HassJob, - HomeAssistant, - callback, -) +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import ( condition, config_validation as cv, diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 7ff351893b1..b56298e36ba 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -7,9 +7,8 @@ from collections.abc import Coroutine from contextlib import suppress from typing import Any -from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass +from zwave_js_server.const import CommandClass, RemoveNodeReason from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode @@ -22,6 +21,7 @@ from zwave_js_server.model.notification import ( from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.persistent_notification import async_create from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, @@ -146,7 +146,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # connect and throw error if connection failed try: - async with timeout(CONNECT_TIMEOUT): + async with asyncio.timeout(CONNECT_TIMEOUT): await client.connect() except InvalidServerVersion as err: if use_addon: @@ -291,6 +291,11 @@ class DriverEvents: controller.on("node removed", self.controller_events.async_on_node_removed) ) + # listen for identify events for the controller + self.config_entry.async_on_unload( + controller.on("identify", self.controller_events.async_on_identify) + ) + async def async_setup_platform(self, platform: Platform) -> None: """Set up platform if needed.""" if platform not in self.platform_setup_tasks: @@ -348,8 +353,13 @@ class ControllerEvents: ) ) - # No need for a ping button or node status sensor for controller nodes - if not node.is_controller_node: + if node.is_controller_node: + # Create a controller status sensor for each device + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{self.config_entry.entry_id}_add_controller_status_sensor", + ) + else: # Create a node status sensor for each device async_dispatcher_send( self.hass, @@ -398,13 +408,13 @@ class ControllerEvents: def async_on_node_removed(self, event: dict) -> None: """Handle node removed event.""" node: ZwaveNode = event["node"] - replaced: bool = event.get("replaced", False) + reason: RemoveNodeReason = event["reason"] # grab device in device registry attached to this node dev_id = get_device_id(self.driver_events.driver, node) device = self.dev_reg.async_get_device(identifiers={dev_id}) # We assert because we know the device exists assert device - if replaced: + if reason in (RemoveNodeReason.REPLACED, RemoveNodeReason.PROXY_REPLACED): self.discovered_value_ids.pop(device.id, None) async_dispatcher_send( @@ -418,6 +428,41 @@ class ControllerEvents: else: self.remove_device(device) + @callback + def async_on_identify(self, event: dict) -> None: + """Handle identify event.""" + # Get node device + node: ZwaveNode = event["node"] + dev_id = get_device_id(self.driver_events.driver, node) + device = self.dev_reg.async_get_device(identifiers={dev_id}) + assert device + device_name = device.name_by_user or device.name + home_id = self.driver_events.driver.controller.home_id + # We do this because we know at this point the controller has its home ID as + # as it is part of the device ID + assert home_id + # In case the user has multiple networks, we should give them more information + # about the network for the controller being identified. + identifier = "" + if len(self.hass.config_entries.async_entries(DOMAIN)) > 1: + if str(home_id) != self.config_entry.title: + identifier = ( + f"`{self.config_entry.title}`, with the home ID `{home_id}`, " + ) + else: + identifier = f"with the home ID `{home_id}` " + async_create( + self.hass, + ( + f"`{device_name}` has just requested the controller for your Z-Wave " + f"network {identifier}to identify itself. No action is needed from " + "you other than to note the source of the request, and you can safely " + "dismiss this notification when ready." + ), + "New Z-Wave Identify Controller Request", + f"{DOMAIN}.identify_controller.{dev_id[1]}", + ) + @callback def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" @@ -551,6 +596,23 @@ class NodeEvents: node, ) + # After ensuring the node is set up in HA, we should check if the node's + # device config has changed, and if so, issue a repair registry entry for a + # possible reinterview + if not node.is_controller_node and await node.async_has_device_config_changed(): + device_name = device.name_by_user or device.name or "Unnamed device" + async_create_issue( + self.hass, + DOMAIN, + f"device_config_file_changed.{device.id}", + data={"device_id": device.id, "device_name": device_name}, + is_fixable=True, + is_persistent=False, + translation_key="device_config_file_changed", + translation_placeholders={"device_name": device_name}, + severity=IssueSeverity.WARNING, + ) + async def async_handle_discovery_info( self, device: dr.DeviceEntry, diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 5fc7da68e99..d93745f7a66 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -55,6 +55,7 @@ from zwave_js_server.model.utils import ( from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api +from homeassistant.components.http import require_admin from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.websocket_api import ( ERR_INVALID_FORMAT, @@ -65,7 +66,6 @@ from homeassistant.components.websocket_api import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import Unauthorized from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.device_registry as dr @@ -514,6 +514,7 @@ async def websocket_network_status( "is_heal_network_active": controller.is_heal_network_active, "inclusion_state": controller.inclusion_state, "rf_region": controller.rf_region, + "status": controller.status, "nodes": [node_status(node) for node in driver.controller.nodes.values()], }, } @@ -1138,6 +1139,7 @@ async def websocket_remove_node( node = event["node"] node_details = { "node_id": node.node_id, + "reason": event["reason"], } connection.send_message( @@ -1729,7 +1731,7 @@ async def websocket_subscribe_log_updates( @callback def async_cleanup() -> None: """Remove signal listeners.""" - hass.async_create_task(driver.async_stop_listening_logs()) + hass.async_create_task(client.async_stop_listening_logs()) for unsub in unsubs: unsub() @@ -1770,7 +1772,7 @@ async def websocket_subscribe_log_updates( ] connection.subscriptions[msg["id"]] = async_cleanup - await driver.async_start_listening_logs() + await client.async_start_listening_logs() connection.send_result(msg[ID]) @@ -2148,10 +2150,9 @@ class FirmwareUploadView(HomeAssistantView): super().__init__() self._dev_reg = dev_reg + @require_admin async def post(self, request: web.Request, device_id: str) -> web.Response: """Handle upload.""" - if not request["hass_user"].is_admin: - raise Unauthorized() hass = request.app["hass"] try: diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 327db05cb00..d511a030fb1 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -507,8 +507,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # 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" + "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, diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 071b562ceea..752e3545114 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -7,7 +7,6 @@ import logging from typing import Any import aiohttp -from async_timeout import timeout from serial.tools import list_ports import voluptuous as vol from zwave_js_server.version import VersionInfo, get_server_version @@ -115,7 +114,7 @@ async def validate_input(hass: HomeAssistant, user_input: dict) -> VersionInfo: async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> VersionInfo: """Return Z-Wave JS version info.""" try: - async with timeout(SERVER_VERSION_TIMEOUT): + async with asyncio.timeout(SERVER_VERSION_TIMEOUT): version_info: VersionInfo = await get_server_version( ws_address, async_get_clientsession(hass) ) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 04db33fdff6..b9b0c3a6e86 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -54,9 +54,8 @@ from .device_automation_helpers import ( CONF_SUBTYPE, VALUE_ID_REGEX, generate_config_parameter_subtype, - get_config_parameter_value_schema, ) -from .helpers import async_get_node_from_device_id +from .helpers import async_get_node_from_device_id, get_value_state_schema ACTION_TYPES = { SERVICE_CLEAR_LOCK_USERCODE, @@ -357,7 +356,7 @@ async def async_get_action_capabilities( property_key=config[ATTR_CONFIG_PARAMETER_BITMASK], endpoint=config[ATTR_ENDPOINT], ) - value_schema = get_config_parameter_value_schema(node, value_id) + value_schema = get_value_state_schema(node.values[value_id]) if value_schema is None: return {} return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 7a60d491b3c..2c375485e6b 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -1,12 +1,7 @@ """Provides helpers for Z-Wave JS device automations.""" from __future__ import annotations -from typing import cast - -import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import ConfigurationValueType -from zwave_js_server.model.node import Node from zwave_js_server.model.value import ConfigurationValue from homeassistant.config_entries import ConfigEntryState @@ -23,24 +18,6 @@ CONF_VALUE_ID = "value_id" VALUE_ID_REGEX = r"([0-9]+-[0-9]+-[0-9]+-).+" -def get_config_parameter_value_schema(node: Node, value_id: str) -> vol.Schema | None: - """Get the extra fields schema for a config parameter value.""" - config_value = cast(ConfigurationValue, node.values[value_id]) - min_ = config_value.metadata.min - max_ = config_value.metadata.max - - if config_value.configuration_value_type in ( - ConfigurationValueType.RANGE, - ConfigurationValueType.MANUAL_ENTRY, - ): - return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) - - if config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: - return vol.In({int(k): v for k, v in config_value.metadata.states.items()}) - - return None - - def generate_config_parameter_subtype(config_value: ConfigurationValue) -> str: """Generate the config parameter name used in a device automation subtype.""" parameter = str(config_value.property_) diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index 3e089362d0b..26b4c637b6e 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -31,11 +31,11 @@ from .device_automation_helpers import ( NODE_STATUSES, async_bypass_dynamic_config_validation, generate_config_parameter_subtype, - get_config_parameter_value_schema, ) from .helpers import ( async_get_node_from_device_id, check_type_schema_map, + get_value_state_schema, get_zwave_value_from_config, remove_keys_with_empty_values, ) @@ -209,7 +209,7 @@ async def async_get_condition_capabilities( # Add additional fields to the automation trigger UI if config[CONF_TYPE] == CONFIG_PARAMETER_TYPE: value_id = config[CONF_VALUE_ID] - value_schema = get_config_parameter_value_schema(node, value_id) + value_schema = get_value_state_schema(node.values[value_id]) if value_schema is None: return {} return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 2fe2b17fe1b..afae214ab2b 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -7,8 +7,9 @@ from typing import Any from zwave_js_server.client import Client from zwave_js_server.const import CommandClass from zwave_js_server.dump import dump_msgs -from zwave_js_server.model.node import Node, NodeDataType +from zwave_js_server.model.node import Node from zwave_js_server.model.value import ValueDataType +from zwave_js_server.util.node import dump_node_state from homeassistant.components.diagnostics import REDACTED from homeassistant.components.diagnostics.util import async_redact_data @@ -54,13 +55,20 @@ def optionally_redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueD return zwave_value -def redact_node_state(node_state: NodeDataType) -> NodeDataType: +def redact_node_state(node_state: dict) -> dict: """Redact node state.""" - redacted_state: NodeDataType = deepcopy(node_state) - redacted_state["values"] = [ - optionally_redact_value_of_zwave_value(zwave_value) - for zwave_value in node_state["values"] - ] + redacted_state: dict = deepcopy(node_state) + # dump_msgs returns values in a list but dump_node_state returns them in a dict + if isinstance(node_state["values"], list): + redacted_state["values"] = [ + optionally_redact_value_of_zwave_value(zwave_value) + for zwave_value in node_state["values"] + ] + else: + redacted_state["values"] = { + value_id: optionally_redact_value_of_zwave_value(zwave_value) + for value_id, zwave_value in node_state["values"].items() + } return redacted_state @@ -129,8 +137,8 @@ async def async_get_config_entry_diagnostics( handshake_msgs = msgs[:-1] network_state = msgs[-1] network_state["result"]["state"]["nodes"] = [ - redact_node_state(async_redact_data(node, KEYS_TO_REDACT)) - for node in network_state["result"]["state"]["nodes"] + redact_node_state(async_redact_data(node_data, KEYS_TO_REDACT)) + for node_data in network_state["result"]["state"]["nodes"] ] return {"messages": [*handshake_msgs, network_state]} @@ -148,7 +156,9 @@ async def async_get_device_diagnostics( node = driver.controller.nodes[node_id] entities = get_device_entities(hass, node, config_entry, device) assert client.version - node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) + node_state = redact_node_state( + async_redact_data(dump_node_state(node), KEYS_TO_REDACT) + ) return { "versionInfo": { "driverVersion": client.version.driver_version, diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 9569ba97167..c879cc1f5b4 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1108,7 +1108,7 @@ def async_discover_single_value( def async_discover_single_configuration_value( value: ConfigurationValue, ) -> Generator[ZwaveDiscoveryInfo, None, None]: - """Run discovery on a single ZWave configuration value and return matching schema info.""" + """Run discovery on single Z-Wave configuration value and return schema matches.""" if value.metadata.writeable and value.metadata.readable: if value.configuration_value_type == ConfigurationValueType.ENUMERATED: yield ZwaveDiscoveryInfo( @@ -1125,36 +1125,29 @@ def async_discover_single_configuration_value( ConfigurationValueType.RANGE, ConfigurationValueType.MANUAL_ENTRY, ): - if value.metadata.type == ValueType.BOOLEAN or ( - value.metadata.min == 0 and value.metadata.max == 1 - ): - yield ZwaveDiscoveryInfo( - node=value.node, - primary_value=value, - assumed_state=False, - platform=Platform.SWITCH, - platform_hint="config_parameter", - platform_data=None, - additional_value_ids_to_watch=set(), - entity_registry_enabled_default=False, - ) - else: - yield ZwaveDiscoveryInfo( - node=value.node, - primary_value=value, - assumed_state=False, - platform=Platform.NUMBER, - platform_hint="config_parameter", - platform_data=None, - additional_value_ids_to_watch=set(), - entity_registry_enabled_default=False, - ) + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.NUMBER, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + elif value.configuration_value_type == ConfigurationValueType.BOOLEAN: + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.SWITCH, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) elif not value.metadata.writeable and value.metadata.readable: - if value.metadata.type == ValueType.BOOLEAN or ( - value.metadata.min == 0 - and value.metadata.max == 1 - and not value.metadata.states - ): + if value.configuration_value_type == ConfigurationValueType.BOOLEAN: yield ZwaveDiscoveryInfo( node=value.node, primary_value=value, diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 2a0f5ff4e72..0b9c68e9664 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -7,13 +7,18 @@ from typing import Any from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver -from zwave_js_server.model.value import Value as ZwaveValue, get_value_id_str +from zwave_js_server.model.value import ( + SetValueResult, + Value as ZwaveValue, + get_value_id_str, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import UNDEFINED from .const import DOMAIN, LOGGER @@ -70,9 +75,9 @@ class ZWaveBaseEntity(Entity): async def _async_poll_value(self, value_or_id: str | ZwaveValue) -> None: """Poll a value.""" - # We log an error instead of raising an exception because this service call occurs - # in a separate task and we don't want to raise the exception in that separate task - # because it is confusing to the user. + # We log an error instead of raising an exception because this service call + # occurs in a separate task and we don't want to raise the exception in that + # separate task because it is confusing to the user. try: await self.info.node.async_poll_value(value_or_id) except BaseZwaveJSServerError as err: @@ -157,6 +162,7 @@ class ZWaveBaseEntity(Entity): name_prefix: str | None = None, ) -> str: """Generate entity name.""" + primary_value = self.info.primary_value name = "" if ( hasattr(self, "entity_description") @@ -174,9 +180,9 @@ class ZWaveBaseEntity(Entity): value_name = alternate_value_name elif include_value_name: value_name = ( - self.info.primary_value.metadata.label - or self.info.primary_value.property_key_name - or self.info.primary_value.property_name + primary_value.metadata.label + or primary_value.property_key_name + or primary_value.property_name or "" ) @@ -184,12 +190,21 @@ class ZWaveBaseEntity(Entity): # Only include non empty additional info if additional_info := [item for item in (additional_info or []) if item]: name = f"{name} {' '.join(additional_info)}" - # append endpoint if > 1 - if ( - self.info.primary_value.endpoint is not None - and self.info.primary_value.endpoint > 1 + + # Only append endpoint to name if there are equivalent values on a lower + # endpoint + if primary_value.endpoint is not None and any( + get_value_id_str( + self.info.node, + primary_value.command_class, + primary_value.property_, + endpoint=endpoint_idx, + property_key=primary_value.property_key, + ) + in self.info.node.values + for endpoint_idx in range(0, primary_value.endpoint) ): - name += f" ({self.info.primary_value.endpoint})" + name += f" ({primary_value.endpoint})" return name @@ -312,7 +327,7 @@ class ZWaveBaseEntity(Entity): new_value: Any, options: dict | None = None, wait_for_result: bool | None = None, - ) -> bool | None: + ) -> SetValueResult | None: """Set value on node.""" try: return await self.info.node.async_set_value( diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 6c54a464837..3b1faa40fa8 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType from .const import ( @@ -252,7 +252,7 @@ def async_get_node_from_entity_id( entity_entry = ent_reg.async_get(entity_id) if entity_entry is None or entity_entry.platform != DOMAIN: - raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity") # Assert for mypy, safe because we know that zwave_js entities are always # tied to a device @@ -414,9 +414,7 @@ def copy_available_params( ) -def get_value_state_schema( - value: ZwaveValue, -) -> vol.Schema | None: +def get_value_state_schema(value: ZwaveValue) -> vol.Schema | None: """Return device automation schema for a config entry.""" if isinstance(value, ConfigurationValue): min_ = value.metadata.min @@ -427,6 +425,9 @@ def get_value_state_schema( ): return vol.All(vol.Coerce(int), vol.Range(min=min_, max=max_)) + if value.configuration_value_type == ConfigurationValueType.BOOLEAN: + return vol.Coerce(bool) + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: return vol.In({int(k): v for k, v in value.metadata.states.items()}) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index b163ace1d24..080074451bd 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,13 +3,13 @@ "name": "Z-Wave", "codeowners": ["@home-assistant/z-wave"], "config_flow": true, - "dependencies": ["usb", "http", "websocket_api"], + "dependencies": ["usb", "http", "repairs", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/zwave_js", "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.49.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.1"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py new file mode 100644 index 00000000000..89f51dddb88 --- /dev/null +++ b/homeassistant/components/zwave_js/repairs.py @@ -0,0 +1,55 @@ +"""Repairs for Z-Wave JS.""" +from __future__ import annotations + +import voluptuous as vol +from zwave_js_server.model.node import Node + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant + +from .helpers import async_get_node_from_device_id + + +class DeviceConfigFileChangedFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, node: Node, device_name: str) -> None: + """Initialize.""" + self.node = node + self.device_name = device_name + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + self.hass.async_create_task(self.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_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str] | None, +) -> RepairsFlow: + """Create flow.""" + + if issue_id.split(".")[0] == "device_config_file_changed": + assert data + return DeviceConfigFileChangedFlow( + async_get_node_from_device_id(hass, data["device_id"]), data["device_name"] + ) + return ConfirmRepairFlow() diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 468d8f0cbda..3c22288a1d6 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -6,7 +6,7 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, NodeStatus +from zwave_js_server.const import CommandClass, ControllerStatus, NodeStatus from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, @@ -91,7 +91,13 @@ from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 0 -STATUS_ICON: dict[NodeStatus, str] = { +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", @@ -485,12 +491,12 @@ async def async_setup_entry( ) -> None: """Set up Z-Wave sensor from config entry.""" client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. @callback def async_add_sensor(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave Sensor.""" - driver = client.driver - assert driver is not None # Driver is ready before platforms are loaded. entities: list[ZWaveBaseEntity] = [] if info.platform_data: @@ -529,18 +535,19 @@ async def async_setup_entry( async_add_entities(entities) + @callback + def async_add_controller_status_sensor() -> None: + """Add controller status sensor.""" + async_add_entities([ZWaveControllerStatusSensor(config_entry, driver)]) + @callback def async_add_node_status_sensor(node: ZwaveNode) -> None: """Add node status sensor.""" - driver = client.driver - assert driver is not None # Driver is ready before platforms are loaded. async_add_entities([ZWaveNodeStatusSensor(config_entry, driver, node)]) @callback def async_add_statistics_sensors(node: ZwaveNode) -> None: """Add statistics sensors.""" - driver = client.driver - assert driver is not None # Driver is ready before platforms are loaded. async_add_entities( [ ZWaveStatisticsSensor( @@ -565,6 +572,14 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_controller_status_sensor", + async_add_controller_status_sensor, + ) + ) + config_entry.async_on_unload( async_dispatcher_connect( hass, @@ -828,7 +843,7 @@ class ZWaveNodeStatusSensor(SensorEntity): @property def icon(self) -> str | None: """Icon of the entity.""" - return STATUS_ICON[self.node.status] + return NODE_STATUS_ICON[self.node.status] async def async_added_to_hass(self) -> None: """Call when entity is added.""" @@ -856,6 +871,71 @@ class ZWaveNodeStatusSensor(SensorEntity): self.async_write_ha_state() +class ZWaveControllerStatusSensor(SensorEntity): + """Representation of a controller status sensor.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_has_entity_name = True + + def __init__(self, config_entry: ConfigEntry, driver: Driver) -> None: + """Initialize a generic Z-Wave device entity.""" + self.config_entry = config_entry + self.controller = driver.controller + node = self.controller.own_node + 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 + self._attr_device_info = get_device_info(driver, node) + + async def async_poll_value(self, _: bool) -> None: + """Poll a value.""" + # We log an error instead of raising an exception because this service call occurs + # in a separate task since it is called via the dispatcher and we don't want to + # raise the exception in that separate task because it is confusing to the user. + LOGGER.error( + "There is no value to refresh for this entity so the zwave_js.refresh_value" + " service won't work for it" + ) + + @callback + def _status_changed(self, _: dict) -> None: + """Call when status event is received.""" + 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. + self.async_on_remove(self.controller.on("status changed", self._status_changed)) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) + # we don't listen for `remove_entity_on_ready_node` signal because this is not + # a regular node + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + self._attr_native_value: str = self.controller.status.name.lower() + + class ZWaveStatisticsSensor(SensorEntity): """Representation of a node/controller statistics sensor.""" diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 133cb407405..44ef3a2269c 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -8,7 +8,7 @@ from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, CommandStatus +from zwave_js_server.const import SET_VALUE_SUCCESS, CommandClass, CommandStatus from zwave_js_server.exceptions import FailedZWaveCommand, SetValueFailed from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode @@ -39,12 +39,6 @@ from .helpers import ( _LOGGER = logging.getLogger(__name__) -SET_VALUE_FAILED_EXC = SetValueFailed( - "Unable to set value, refer to " - "https://zwave-js.github.io/node-zwave-js/#/api/node?id=setvalue for " - "possible reasons" -) - def parameter_name_does_not_need_bitmask( val: dict[str, int | str | list[str]] @@ -538,16 +532,20 @@ class ZWaveServices: nodes_list = list(nodes) # multiple set_values my fail so we will track the entire list set_value_failed_nodes_list: list[ZwaveNode | Endpoint] = [] - for node_, success in get_valid_responses_from_results(nodes_list, results): - if success is False: - # If we failed to set a value, add node to SetValueFailed exception list + set_value_failed_error_list: list[SetValueFailed] = [] + for node_, result in get_valid_responses_from_results(nodes_list, results): + if result and result.status not in SET_VALUE_SUCCESS: + # If we failed to set a value, add node to exception list set_value_failed_nodes_list.append(node_) + set_value_failed_error_list.append( + SetValueFailed(f"{result.status} {result.message}") + ) - # Add the SetValueFailed exception to the results and the nodes to the node - # list. No-op if there are no SetValueFailed exceptions + # Add the exception to the results and the nodes to the node list. No-op if + # no set value commands failed raise_exceptions_from_results( (*nodes_list, *set_value_failed_nodes_list), - (*results, *([SET_VALUE_FAILED_EXC] * len(set_value_failed_nodes_list))), + (*results, *set_value_failed_error_list), ) async def async_multicast_set_value(self, service: ServiceCall) -> None: @@ -611,7 +609,7 @@ class ZWaveServices: new_value = str(new_value) try: - success = await async_multicast_set_value( + result = await async_multicast_set_value( client=client, new_value=new_value, value_data=value, @@ -621,10 +619,10 @@ class ZWaveServices: except FailedZWaveCommand as err: raise HomeAssistantError("Unable to set value via multicast") from err - if success is False: + if result.status not in SET_VALUE_SUCCESS: raise HomeAssistantError( "Unable to set value via multicast" - ) from SetValueFailed + ) from SetValueFailed(f"{result.status} {result.message}") async def async_ping(self, service: ServiceCall) -> None: """Ping node(s).""" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 934307947d8..6435c6b7a54 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -161,6 +161,17 @@ } } } + }, + "device_config_file_changed": { + "title": "Z-Wave device configuration file changed: {device_name}", + "fix_flow": { + "step": { + "confirm": { + "title": "Z-Wave device configuration file changed: {device_name}", + "description": "Z-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." + } + } + } } }, "services": { diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 86cebe81180..e35f55d6fda 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -8,8 +8,9 @@ from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from .const import DOMAIN, PLATFORMS, ZWaveMePlatform diff --git a/homeassistant/config.py b/homeassistant/config.py index eed296baf0e..7c3bd2e7bfe 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -257,10 +257,10 @@ CORE_CONFIG_SCHEMA = vol.All( vol.Optional(CONF_INTERNAL_URL): cv.url, vol.Optional(CONF_EXTERNAL_URL): cv.url, vol.Optional(CONF_ALLOWLIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + cv.ensure_list, [vol.IsDir()] ), vol.Optional(LEGACY_CONF_WHITELIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + cv.ensure_list, [vol.IsDir()] ), vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All( cv.ensure_list, [cv.url] @@ -297,7 +297,6 @@ CORE_CONFIG_SCHEMA = vol.All( ], _no_duplicate_auth_mfa_module, ), - # pylint: disable-next=no-value-for-parameter vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, vol.Optional(CONF_CURRENCY): _validate_currency, @@ -337,7 +336,6 @@ async def async_create_default_config(hass: HomeAssistant) -> bool: Return if creation was successful. """ - assert hass.config.config_dir return await hass.async_add_executor_job( _write_default_config, hass.config.config_dir ) @@ -390,10 +388,7 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: This function allow a component inside the asyncio loop to reload its configuration by itself. Include package merge. """ - if hass.config.config_dir is None: - secrets = None - else: - secrets = Secrets(Path(hass.config.config_dir)) + secrets = Secrets(Path(hass.config.config_dir)) # Not using async_add_executor_job because this is an internal method. config = await hass.loop.run_in_executor( diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 15fcb9a50de..02117c3ac5a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections import ChainMap from collections.abc import Callable, Coroutine, Generator, Iterable, Mapping from contextvars import ContextVar from copy import deepcopy @@ -964,10 +963,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): Handler key is the domain of the component that we want to set up. """ - await _load_integration(self.hass, handler_key, self._hass_config) - if (handler := HANDLERS.get(handler_key)) is None: - raise data_entry_flow.UnknownHandler - + handler = await _async_get_flow_handler( + self.hass, handler_key, self._hass_config + ) if not context or "source" not in context: raise KeyError("Context not set or doesn't have a source set") @@ -1466,14 +1464,12 @@ def _async_abort_entries_match( if match_dict is None: match_dict = {} # Match any entry for entry in other_entries: - if all( - item - in ChainMap( - entry.options, # type: ignore[arg-type] - entry.data, # type: ignore[arg-type] - ).items() - for item in match_dict.items() - ): + options_items = entry.options.items() + data_items = entry.data.items() + for kv in match_dict.items(): + if kv not in options_items and kv not in data_items: + break + else: raise data_entry_flow.AbortFlow("already_configured") @@ -1815,6 +1811,14 @@ class ConfigFlow(data_entry_flow.FlowHandler): class OptionsFlowManager(data_entry_flow.FlowManager): """Flow to set options for a configuration entry.""" + def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: + """Return config entry or raise if not found.""" + entry = self.hass.config_entries.async_get_entry(config_entry_id) + if entry is None: + raise UnknownEntry(config_entry_id) + + return entry + async def async_create_flow( self, handler_key: str, @@ -1826,16 +1830,9 @@ class OptionsFlowManager(data_entry_flow.FlowManager): Entry_id and flow.handler is the same thing to map entry with flow. """ - entry = self.hass.config_entries.async_get_entry(handler_key) - if entry is None: - raise UnknownEntry(handler_key) - - await _load_integration(self.hass, entry.domain, {}) - - if entry.domain not in HANDLERS: - raise data_entry_flow.UnknownHandler - - return HANDLERS[entry.domain].async_get_options_flow(entry) + entry = self._async_get_config_entry(handler_key) + handler = await _async_get_flow_handler(self.hass, entry.domain, {}) + return handler.async_get_options_flow(entry) async def async_finish_flow( self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult @@ -1858,6 +1855,14 @@ class OptionsFlowManager(data_entry_flow.FlowManager): result["result"] = True return result + async def _async_setup_preview(self, flow: data_entry_flow.FlowHandler) -> None: + """Set up preview for an option flow handler.""" + entry = self._async_get_config_entry(flow.handler) + await _load_integration(self.hass, entry.domain, {}) + if entry.domain not in self._preview: + self._preview.add(entry.domain) + await flow.async_setup_preview(self.hass) + class OptionsFlow(data_entry_flow.FlowHandler): """Base class for config options flows.""" @@ -2042,3 +2047,22 @@ async def _load_integration( err, ) raise data_entry_flow.UnknownHandler + + +async def _async_get_flow_handler( + hass: HomeAssistant, domain: str, hass_config: ConfigType +) -> type[ConfigFlow]: + """Get a flow handler for specified domain.""" + + # First check if there is a handler registered for the domain + if loader.is_component_module_loaded(hass, f"{domain}.config_flow") and ( + handler := HANDLERS.get(domain) + ): + return handler + + await _load_integration(hass, domain, hass_config) + + if handler := HANDLERS.get(domain): + return handler + + raise data_entry_flow.UnknownHandler diff --git a/homeassistant/const.py b/homeassistant/const.py index f803a673265..cfdb5095128 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,8 +6,8 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 8 -PATCH_VERSION: Final = "4" +MINOR_VERSION: Final = 9 +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) @@ -39,6 +39,7 @@ class Platform(StrEnum): HUMIDIFIER = "humidifier" IMAGE = "image" IMAGE_PROCESSING = "image_processing" + LAWN_MOWER = "lawn_mower" LIGHT = "light" LOCK = "lock" MAILBOX = "mailbox" @@ -57,6 +58,7 @@ class Platform(StrEnum): TTS = "tts" VACUUM = "vacuum" UPDATE = "update" + WAKE_WORD = "wake_word" WATER_HEATER = "water_heater" WEATHER = "weather" diff --git a/homeassistant/core.py b/homeassistant/core.py index 3673f9acba5..f2921e244ab 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -26,19 +26,9 @@ import re import threading import time from time import monotonic -from typing import ( - TYPE_CHECKING, - Any, - Generic, - ParamSpec, - Self, - TypeVar, - cast, - overload, -) +from typing import TYPE_CHECKING, Any, Generic, ParamSpec, Self, TypeVar, cast, overload from urllib.parse import urlparse -import async_timeout import voluptuous as vol import yarl @@ -118,7 +108,7 @@ _P = ParamSpec("_P") # Internal; not helpers.typing.UNDEFINED due to circular dependency _UNDEF: dict[Any, Any] = {} _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) -CALLBACK_TYPE = Callable[[], None] # pylint: disable=invalid-name +CALLBACK_TYPE = Callable[[], None] CORE_STORAGE_KEY = "core.config" CORE_STORAGE_VERSION = 1 @@ -184,6 +174,16 @@ def valid_entity_id(entity_id: str) -> bool: return VALID_ENTITY_ID.match(entity_id) is not None +def validate_state(state: str) -> str: + """Validate a state, raise if it not valid.""" + if len(state) > MAX_LENGTH_STATE_STATE: + raise InvalidStateError( + f"Invalid state with length {len(state)}. " + "State max length is 255 characters." + ) + return state + + def callback(func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" setattr(func, "_hass_callback", True) @@ -298,13 +298,13 @@ class HomeAssistant: http: HomeAssistantHTTP = None # type: ignore[assignment] config_entries: ConfigEntries = None # type: ignore[assignment] - def __new__(cls) -> HomeAssistant: + def __new__(cls, config_dir: str) -> HomeAssistant: """Set the _hass thread local data.""" hass = super().__new__(cls) _hass.hass = hass return hass - def __init__(self) -> None: + def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" self.loop = asyncio.get_running_loop() self._tasks: set[asyncio.Future[Any]] = set() @@ -312,7 +312,7 @@ class HomeAssistant: self.bus = EventBus(self) self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) - self.config = Config(self) + self.config = Config(self, config_dir) self.components = loader.Components(self) self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. @@ -815,7 +815,7 @@ class HomeAssistant: ) task.cancel("Home Assistant stage 2 shutdown") try: - async with async_timeout.timeout(0.1): + async with asyncio.timeout(0.1): await task except asyncio.CancelledError: pass @@ -857,8 +857,7 @@ class HomeAssistant: if ( not handle.cancelled() and (args := handle._args) # pylint: disable=protected-access - # pylint: disable-next=unidiomatic-typecheck - and type(job := args[0]) is HassJob + and type(job := args[0]) is HassJob # noqa: E721 and job.cancel_on_shutdown ): handle.cancel() @@ -1035,6 +1034,11 @@ class EventBus: listeners = self._listeners.get(event_type, []) match_all_listeners = self._match_all_listeners + event = Event(event_type, event_data, origin, time_fired, context) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Bus:Handling %s", event) + if not listeners and not match_all_listeners: return @@ -1042,11 +1046,6 @@ class EventBus: if event_type != EVENT_HOMEASSISTANT_CLOSE: listeners = match_all_listeners + listeners - event = Event(event_type, event_data, origin, time_fired, context) - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Bus:Handling %s", event) - for job, event_filter, run_immediately in listeners: if event_filter is not None: try: @@ -1262,11 +1261,7 @@ class State: "Format should be ." ) - if len(state) > MAX_LENGTH_STATE_STATE: - raise InvalidStateError( - f"Invalid state encountered for entity ID: {entity_id}. " - "State max length is 255 characters." - ) + validate_state(state) self.entity_id = entity_id.lower() self.state = state @@ -2021,7 +2016,7 @@ class ServiceRegistry: class Config: """Configuration settings for Home Assistant.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" self.hass = hass @@ -2057,7 +2052,7 @@ class Config: self.api: ApiConfig | None = None # Directory that holds the configuration - self.config_dir: str | None = None + self.config_dir: str = config_dir # List of allowed external dirs to access self.allowlist_external_dirs: set[str] = set() @@ -2088,8 +2083,6 @@ class Config: Async friendly. """ - if self.config_dir is None: - raise HomeAssistantError("config_dir is not set") return os.path.join(self.config_dir, *path) def is_allowed_external_url(self, url: str) -> bool: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e0408a24b2e..467fc3b5228 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -95,6 +95,7 @@ class FlowResult(TypedDict, total=False): last_step: bool | None menu_options: list[str] | dict[str, str] options: Mapping[str, Any] + preview: str | None progress_action: str reason: str required: bool @@ -135,6 +136,7 @@ class FlowManager(abc.ABC): ) -> None: """Initialize the flow manager.""" self.hass = hass + self._preview: set[str] = set() self._progress: dict[str, FlowHandler] = {} self._handler_progress_index: dict[str, set[str]] = {} self._init_data_process_index: dict[type, set[str]] = {} @@ -395,6 +397,10 @@ class FlowManager(abc.ABC): flow.flow_id, flow.handler, err.reason, err.description_placeholders ) + # Setup the flow handler's preview if needed + if result.get("preview") is not None: + await self._async_setup_preview(flow) + if not isinstance(result["type"], FlowResultType): result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] report( @@ -429,6 +435,12 @@ class FlowManager(abc.ABC): return result + async def _async_setup_preview(self, flow: FlowHandler) -> None: + """Set up preview for a flow handler.""" + if flow.handler not in self._preview: + self._preview.add(flow.handler) + await flow.async_setup_preview(self.hass) + class FlowHandler: """Handle a data entry flow.""" @@ -504,6 +516,7 @@ class FlowHandler: 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( @@ -515,6 +528,7 @@ class FlowHandler: errors=errors, description_placeholders=description_placeholders, last_step=last_step, # Display next or submit button in frontend + preview=preview, # Display preview component in frontend ) @callback @@ -635,6 +649,10 @@ class FlowHandler: def async_remove(self) -> None: """Notification that the flow has been removed.""" + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + @callback def _create_abort_data( diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 10221d1d589..7d84dc87cbe 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -10,6 +10,7 @@ FLOWS = { "integration", "min_max", "switch_as_x", + "template", "threshold", "tod", "utility_meter", @@ -81,6 +82,7 @@ FLOWS = { "cloudflare", "co2signal", "coinbase", + "comelit", "control4", "coolmaster", "cpuspeed", @@ -395,6 +397,7 @@ FLOWS = { "rympro", "sabnzbd", "samsungtv", + "schlage", "scrape", "screenlogic", "season", @@ -478,6 +481,7 @@ FLOWS = { "traccar", "tractive", "tradfri", + "trafikverket_camera", "trafikverket_ferry", "trafikverket_train", "trafikverket_weatherstation", @@ -505,6 +509,7 @@ FLOWS = { "vilfo", "vizio", "vlc_telnet", + "vodafone_station", "voip", "volumio", "volvooncall", @@ -532,6 +537,7 @@ FLOWS = { "yale_smart_alarm", "yalexs_ble", "yamaha_musiccast", + "yardian", "yeelight", "yolink", "youless", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 350bcde8236..ef496e7b58b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -883,6 +883,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "comelit": { + "name": "Comelit SimpleHome", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "comfoconnect": { "name": "Zehnder ComfoAir Q", "integration_type": "hub", @@ -907,6 +913,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "coned": { + "name": "Consolidated Edison (ConEd)", + "integration_type": "virtual", + "supported_by": "opower" + }, "control4": { "name": "Control4", "integration_type": "hub", @@ -1113,7 +1124,7 @@ }, "discovergy": { "name": "Discovergy", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, @@ -3485,7 +3496,7 @@ "moon": { "integration_type": "service", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "calculated" }, "mopeka": { "name": "Mopeka", @@ -4069,6 +4080,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "oru_opower": { + "name": "Orange and Rockland Utilities (ORU) Opower", + "integration_type": "virtual", + "supported_by": "opower" + }, "orvibo": { "name": "Orvibo", "integration_type": "hub", @@ -4849,6 +4865,12 @@ "config_flow": false, "iot_class": "local_push" }, + "schlage": { + "name": "Schlage", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "schluter": { "name": "Schluter", "integration_type": "hub", @@ -5647,12 +5669,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "template": { - "name": "Template", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" - }, "tensorflow": { "name": "TensorFlow", "integration_type": "hub", @@ -5866,6 +5882,12 @@ "trafikverket": { "name": "Trafikverket", "integrations": { + "trafikverket_camera": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Trafikverket Camera" + }, "trafikverket_ferry": { "integration_type": "hub", "config_flow": true, @@ -6184,6 +6206,12 @@ } } }, + "vodafone_station": { + "name": "Vodafone Station", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "voicerss": { "name": "VoiceRSS", "integration_type": "hub", @@ -6500,6 +6528,12 @@ } } }, + "yardian": { + "name": "Yardian", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "yeelight": { "name": "Yeelight", "integrations": { @@ -6677,6 +6711,12 @@ "config_flow": true, "iot_class": "calculated" }, + "template": { + "name": "Template", + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_push" + }, "threshold": { "integration_type": "helper", "config_flow": true, @@ -6690,7 +6730,7 @@ "tod": { "integration_type": "helper", "config_flow": true, - "iot_class": "local_push" + "iot_class": "calculated" }, "utility_meter": { "integration_type": "helper", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 6b5676c4a25..3874a06ab4b 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -683,6 +683,12 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_uzg-01._tcp.local.": [ + { + "domain": "zha", + "name": "uzg-01*", + }, + ], "_viziocast._tcp.local.": [ { "domain": "vizio", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 8208c774887..ac253d49254 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -13,7 +13,6 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout -import async_timeout from homeassistant import config_entries from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ @@ -170,7 +169,7 @@ async def async_aiohttp_proxy_web( ) -> web.StreamResponse | None: """Stream websession request to aiohttp web response.""" try: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): req = await web_coro except asyncio.CancelledError: @@ -211,7 +210,7 @@ async def async_aiohttp_proxy_stream( # Suppressing something went wrong fetching data, closed connection with suppress(asyncio.TimeoutError, aiohttp.ClientError): while hass.is_running: - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): data = await stream.read(buffer_size) if not data: diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index a580c013cd0..1e1cac050f1 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -94,8 +94,6 @@ async def async_check_ha_config_file( # noqa: C901 if not await hass.async_add_executor_job(os.path.isfile, config_path): return result.add_error("File configuration.yaml not found.") - assert hass.config.config_dir is not None - config = await hass.async_add_executor_job( load_yaml_config_file, config_path, diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index fe4e5473092..4fd8948843e 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -16,7 +16,6 @@ import time from typing import Any, cast from aiohttp import client, web -import async_timeout import jwt import voluptuous as vol from yarl import URL @@ -287,7 +286,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): return self.async_external_step_done(next_step_id=next_step) try: - async with async_timeout.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC): + async with asyncio.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC): url = await self.async_generate_authorize_url() except asyncio.TimeoutError as err: _LOGGER.error("Timeout generating authorize url: %s", err) @@ -311,7 +310,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): _LOGGER.debug("Creating config entry from external data") try: - async with async_timeout.timeout(OAUTH_TOKEN_TIMEOUT_SEC): + async with asyncio.timeout(OAUTH_TOKEN_TIMEOUT_SEC): token = await self.flow_impl.async_resolve_external_data( self.external_data ) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 122fd752a84..a4018101d0e 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -102,8 +102,6 @@ import homeassistant.util.dt as dt_util from . import script_variables as script_variables_helper, template as template_helper -# pylint: disable=invalid-name - TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" @@ -586,7 +584,7 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if type(value) is str: # pylint: disable=unidiomatic-typecheck + if type(value) is str: # noqa: E721 return value if isinstance(value, template_helper.ResultWrapper): @@ -743,7 +741,6 @@ def socket_timeout(value: Any | None) -> object: raise vol.Invalid(f"Invalid socket timeout: {err}") from err -# pylint: disable=no-value-for-parameter def url( value: Any, _schema_list: frozenset[UrlProtocolSchema] = EXTERNAL_URL_PROTOCOL_SCHEMA_LIST, @@ -1122,6 +1119,7 @@ def _no_yaml_config_schema( # pylint: disable-next=import-outside-toplevel from .issue_registry import IssueSeverity, async_create_issue + # HomeAssistantError is raised if called from the wrong thread with contextlib.suppress(HomeAssistantError): hass = async_get_hass() async_create_issue( @@ -1359,7 +1357,7 @@ STATE_CONDITION_ATTRIBUTE_SCHEMA = vol.Schema( ) -def STATE_CONDITION_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name +def STATE_CONDITION_SCHEMA(value: Any) -> dict: """Validate a state condition.""" if not isinstance(value, dict): raise vol.Invalid("Expected a dictionary") diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index e3e4b4f0de8..aa4ef36b251 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -90,7 +90,7 @@ class FlowManagerIndexView(_BaseFlowManagerView): class FlowManagerResourceView(_BaseFlowManagerView): """View to interact with the flow manager.""" - async def get(self, request: web.Request, flow_id: str) -> web.Response: + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: """Get the current state of a data_entry_flow.""" try: result = await self._flow_mgr.async_configure(flow_id) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 4e5d152135a..54b90077cdc 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -64,9 +64,7 @@ class Debouncer(Generic[_R_co]): async def async_call(self) -> None: """Call the function.""" if self._shutdown_requested: - self.logger.warning( - "Debouncer call ignored as shutdown has been requested." - ) + self.logger.debug("Debouncer call ignored as shutdown has been requested.") return assert self._job is not None diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 4dd9233c6ab..9c2492d65e8 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -28,7 +28,6 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from . import entity_registry - from .entity import DeviceInfo _LOGGER = logging.getLogger(__name__) @@ -65,6 +64,26 @@ DISABLED_CONFIG_ENTRY = DeviceEntryDisabler.CONFIG_ENTRY.value DISABLED_INTEGRATION = DeviceEntryDisabler.INTEGRATION.value DISABLED_USER = DeviceEntryDisabler.USER.value + +class DeviceInfo(TypedDict, total=False): + """Entity device information for device registry.""" + + configuration_url: str | URL | None + connections: set[tuple[str, str]] + default_manufacturer: str + default_model: str + default_name: str + entry_type: DeviceEntryType | None + identifiers: set[tuple[str, str]] + manufacturer: str | None + model: str | None + name: str | None + suggested_area: str | None + sw_version: str | None + hw_version: str | None + via_device: tuple[str, str] + + DEVICE_INFO_TYPES = { # Device info is categorized by finding the first device info type which has all # the keys of the device info. The link device info type must be kept first @@ -139,7 +158,7 @@ class DeviceInfoError(HomeAssistantError): def _validate_device_info( - config_entry: ConfigEntry | None, + config_entry: ConfigEntry, device_info: DeviceInfo, ) -> str: """Process a device info.""" @@ -148,7 +167,7 @@ def _validate_device_info( # If no keys or not enough info to match up, abort if not device_info.get("connections") and not device_info.get("identifiers"): raise DeviceInfoError( - config_entry.domain if config_entry else "unknown", + config_entry.domain, device_info, "device info must include at least one of identifiers or connections", ) @@ -163,7 +182,7 @@ def _validate_device_info( if device_info_type is None: raise DeviceInfoError( - config_entry.domain if config_entry else "unknown", + config_entry.domain, device_info, ( "device info needs to either describe a device, " @@ -508,6 +527,10 @@ class DeviceRegistry: device_info[key] = val # type: ignore[literal-required] config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + if config_entry is None: + raise HomeAssistantError( + f"Can't link device to unknown config entry {config_entry_id}" + ) device_info_type = _validate_device_info(config_entry, device_info) if identifiers is None or identifiers is UNDEFINED: @@ -531,11 +554,7 @@ class DeviceRegistry: ) self.devices[device.id] = device # If creating a new device, default to the config entry name - if ( - device_info_type == "primary" - and (not name or name is UNDEFINED) - and config_entry - ): + if device_info_type == "primary" and (not name or name is UNDEFINED): name = config_entry.title if default_manufacturer is not UNDEFINED and device.manufacturer is None: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7d240cc0320..e946c41d3b8 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -12,10 +12,9 @@ import logging import math import sys from timeit import default_timer as timer -from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, final +from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, final import voluptuous as vol -from yarl import URL from homeassistant.backports.functools import cached_property from homeassistant.config import DATA_CUSTOMIZE @@ -36,12 +35,16 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError +from homeassistant.exceptions import ( + HomeAssistantError, + InvalidStateError, + NoEntitySpecifiedError, +) from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er -from .device_registry import DeviceEntryType, EventDeviceRegistryUpdatedData +from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData from .event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, @@ -175,25 +178,6 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: return entry.unit_of_measurement -class DeviceInfo(TypedDict, total=False): - """Entity device information for device registry.""" - - configuration_url: str | URL | None - connections: set[tuple[str, str]] - default_manufacturer: str - default_model: str - default_name: str - entry_type: DeviceEntryType | None - identifiers: set[tuple[str, str]] - manufacturer: str | None - model: str | None - name: str | None - suggested_area: str | None - sw_version: str | None - hw_version: str | None - via_device: tuple[str, str] - - ENTITY_CATEGORIES_SCHEMA: Final = vol.Coerce(EntityCategory) @@ -776,31 +760,10 @@ class Entity(ABC): return f"{device_name} {name}" if device_name else name @callback - def _async_write_ha_state(self) -> None: - """Write the state to the state machine.""" - if self._platform_state == EntityPlatformState.REMOVED: - # Polling returned after the entity has already been removed - return - - hass = self.hass - entity_id = self.entity_id + def _async_generate_attributes(self) -> tuple[str, dict[str, Any]]: + """Calculate state string and attribute mapping.""" entry = self.registry_entry - if entry and entry.disabled_by: - if not self._disabled_reported: - self._disabled_reported = True - _LOGGER.warning( - ( - "Entity %s is incorrectly being triggered for updates while it" - " is disabled. This is a bug in the %s integration" - ), - entity_id, - self.platform.platform_name, - ) - return - - start = timer() - attr = self.capability_attributes attr = dict(attr) if attr else {} @@ -838,6 +801,33 @@ class Entity(ABC): if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features + return (state, attr) + + @callback + def _async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self._platform_state == EntityPlatformState.REMOVED: + # Polling returned after the entity has already been removed + return + + hass = self.hass + entity_id = self.entity_id + + if (entry := self.registry_entry) and entry.disabled_by: + if not self._disabled_reported: + self._disabled_reported = True + _LOGGER.warning( + ( + "Entity %s is incorrectly being triggered for updates while it" + " is disabled. This is a bug in the %s integration" + ), + entity_id, + self.platform.platform_name, + ) + return + + start = timer() + state, attr = self._async_generate_attributes() end = timer() if end - start > 0.4 and not self._slow_reported: @@ -862,7 +852,15 @@ class Entity(ABC): self._context = None self._context_set = None - hass.states.async_set(entity_id, state, attr, self.force_update, self._context) + try: + hass.states.async_set( + entity_id, state, attr, self.force_update, self._context + ) + except InvalidStateError: + _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) + hass.states.async_set( + entity_id, STATE_UNKNOWN, {}, self.force_update, self._context + ) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index b7dadcf0f67..c164e3b1052 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -26,10 +26,7 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) -from homeassistant.exceptions import ( - HomeAssistantError, - PlatformNotReady, -) +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.generated import languages from homeassistant.setup import async_start_setup from homeassistant.util.async_ import run_callback_threadsafe @@ -621,8 +618,13 @@ class EntityPlatform: **device_info, ) except dev_reg.DeviceInfoError as exc: - self.logger.error("Ignoring invalid device info: %s", str(exc)) - device = None + self.logger.error( + "%s: Not adding entity with invalid device info: %s", + self.platform_name, + str(exc), + ) + entity.add_to_platform_abort() + return else: device = None diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a46dd3c3a52..ff2ca255279 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -15,10 +15,9 @@ from datetime import datetime, timedelta from enum import StrEnum import logging import time -from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict, TypeVar, cast import attr -from typing_extensions import NotRequired import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index e615a6422f0..51a8f1f1982 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -68,6 +68,10 @@ _ENTITIES_LISTENER = "entities" _LOGGER = logging.getLogger(__name__) +# Used to spread async_track_utc_time_change listeners and DataUpdateCoordinator +# refresh cycles between RANDOM_MICROSECOND_MIN..RANDOM_MICROSECOND_MAX. +# The values have been determined experimentally in production testing, background +# in PR https://github.com/home-assistant/core/pull/82233 RANDOM_MICROSECOND_MIN = 50000 RANDOM_MICROSECOND_MAX = 500000 @@ -911,7 +915,12 @@ class TrackTemplateResultInfo: """Return the representation.""" return f"" - def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: + def async_setup( + self, + raise_on_template_error: bool, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: """Activation of template tracking.""" block_render = False super_template = self._track_templates[0] if self._has_super_template else None @@ -921,7 +930,7 @@ class TrackTemplateResultInfo: template = super_template.template variables = super_template.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) # If the super template did not render to True, don't update other templates @@ -942,17 +951,20 @@ class TrackTemplateResultInfo: template = track_template_.template variables = track_template_.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) if info.exception: if raise_on_template_error: raise info.exception - _LOGGER.error( - "Error while processing template: %s", - track_template_.template, - exc_info=info.exception, - ) + if not log_fn: + _LOGGER.error( + "Error while processing template: %s", + track_template_.template, + exc_info=info.exception, + ) + else: + log_fn(logging.ERROR, str(info.exception)) self._track_state_changes = async_track_state_change_filtered( self.hass, _render_infos_to_track_states(self._info.values()), self._refresh @@ -960,7 +972,7 @@ class TrackTemplateResultInfo: self._update_time_listeners() _LOGGER.debug( ( - "Template group %s listens for %s, first render blocker by super" + "Template group %s listens for %s, first render blocked by super" " template: %s" ), self._track_templates, @@ -1229,6 +1241,7 @@ def async_track_template_result( action: TrackTemplateResultListener, raise_on_template_error: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, has_super_template: bool = False, ) -> TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. @@ -1260,6 +1273,9 @@ def async_track_template_result( tracking. strict When set to True, raise on undefined variables. + log_fn + If not None, template error messages will logging by calling log_fn + instead of the normal logging facility. has_super_template When set to True, the first template will block rendering of other templates if it doesn't render as True. @@ -1270,7 +1286,7 @@ def async_track_template_result( """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) - tracker.async_setup(raise_on_template_error, strict=strict) + tracker.async_setup(raise_on_template_error, strict=strict, log_fn=log_fn) return tracker @@ -1323,9 +1339,7 @@ def async_track_same_state( if not async_check_same_func(entity, from_state, to_state): clear_listener() - async_remove_state_for_listener = async_track_point_in_utc_time( - hass, state_for_listener, dt_util.utcnow() + period - ) + async_remove_state_for_listener = async_call_later(hass, period, state_for_listener) if entity_ids == MATCH_ALL: async_remove_state_for_cancel = hass.bus.async_listen( @@ -1430,6 +1444,37 @@ def async_track_point_in_utc_time( track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_time) +@callback +@bind_hass +def async_call_at( + hass: HomeAssistant, + action: HassJob[[datetime], Coroutine[Any, Any, None] | None] + | Callable[[datetime], Coroutine[Any, Any, None] | None], + loop_time: float, +) -> CALLBACK_TYPE: + """Add a listener that is called at .""" + + @callback + def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: + """Call the action.""" + hass.async_run_hass_job(job, time_tracker_utcnow()) + + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"call_at {loop_time}") + ) + cancel_callback = hass.loop.call_at(loop_time, run_action, job) + + @callback + def unsub_call_later_listener() -> None: + """Cancel the call_later.""" + assert cancel_callback is not None + cancel_callback.cancel() + + return unsub_call_later_listener + + @callback @bind_hass def async_call_later( @@ -1479,24 +1524,19 @@ def async_track_time_interval( """Add a listener that fires repetitively at every timedelta interval.""" 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 ) - def next_interval() -> datetime: - """Return the next interval.""" - return dt_util.utcnow() + interval - @callback def interval_listener(now: datetime) -> None: """Handle elapsed intervals.""" nonlocal remove nonlocal interval_listener_job - remove = async_track_point_in_utc_time( - hass, interval_listener_job, next_interval() - ) + remove = async_call_later(hass, interval_seconds, interval_listener_job) hass.async_run_hass_job(job, now) if name: @@ -1507,7 +1547,7 @@ def async_track_time_interval( interval_listener_job = HassJob( interval_listener, job_name, cancel_on_shutdown=cancel_on_shutdown ) - remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) + remove = async_call_later(hass, interval_seconds, interval_listener_job) def remove_listener() -> None: """Remove interval listener.""" @@ -1640,7 +1680,7 @@ def async_track_utc_time_change( matching_seconds = dt_util.parse_time_expression(second, 0, 59) matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) - # Avoid aligning all time trackers to the same second + # Avoid aligning all time trackers to the same fraction of a second # since it can create a thundering herd problem # https://github.com/home-assistant/core/issues/82231 microsecond = randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index 9bd6ebffadb..27d568a13de 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -95,16 +95,18 @@ class IssueRegistryStore(Store[dict[str, list[dict[str, Any]]]]): class IssueRegistry: """Class to hold a registry of issues.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, *, read_only: bool = False) -> None: """Initialize the issue registry.""" self.hass = hass self.issues: dict[tuple[str, str], IssueEntry] = {} + self._read_only = read_only self._store = IssueRegistryStore( hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, atomic_writes=True, minor_version=STORAGE_VERSION_MINOR, + read_only=read_only, ) @callback @@ -154,7 +156,7 @@ class IssueRegistry: {"action": "create", "domain": domain, "issue_id": issue_id}, ) else: - issue = self.issues[(domain, issue_id)] = dataclasses.replace( + replacement = dataclasses.replace( issue, active=True, breaks_in_ha_version=breaks_in_ha_version, @@ -167,10 +169,14 @@ class IssueRegistry: translation_key=translation_key, translation_placeholders=translation_placeholders, ) - self.hass.bus.async_fire( - EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, - {"action": "update", "domain": domain, "issue_id": issue_id}, - ) + # Only fire is something changed + if replacement != issue: + issue = self.issues[(domain, issue_id)] = replacement + self.async_schedule_save() + self.hass.bus.async_fire( + EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, + {"action": "update", "domain": domain, "issue_id": issue_id}, + ) return issue @@ -274,10 +280,10 @@ def async_get(hass: HomeAssistant) -> IssueRegistry: return cast(IssueRegistry, hass.data[DATA_REGISTRY]) -async def async_load(hass: HomeAssistant) -> None: +async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None: """Load issue registry.""" assert DATA_REGISTRY not in hass.data - hass.data[DATA_REGISTRY] = IssueRegistry(hass) + hass.data[DATA_REGISTRY] = IssueRegistry(hass, read_only=read_only) await hass.data[DATA_REGISTRY].async_load() diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 38c23050885..e94093cfd2f 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Final import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( # pylint: disable=unused-import # noqa: F401 +from homeassistant.util.json import ( # noqa: F401 JSON_DECODE_EXCEPTIONS, JSON_ENCODE_EXCEPTIONS, SerializationError, @@ -53,6 +53,8 @@ def json_encoder_default(obj: Any) -> Any: return obj.as_dict() if isinstance(obj, Path): return obj.as_posix() + if isinstance(obj, datetime.datetime): + return obj.isoformat() raise TypeError diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 653594f2808..20a5d8de5a8 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -78,6 +78,9 @@ class SchemaFlowFormStep(SchemaFlowStep): have priority over the suggested values. """ + preview: str | None = None + """Optional preview component.""" + @dataclass(slots=True) class SchemaFlowMenuStep(SchemaFlowStep): @@ -237,6 +240,7 @@ class SchemaCommonFlowHandler: data_schema=data_schema, errors=errors, last_step=last_step, + preview=form_step.preview, ) async def _async_menu_step( @@ -271,7 +275,10 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): raise UnknownHandler return SchemaOptionsFlowHandler( - config_entry, cls.options_flow, cls.async_options_flow_finished + config_entry, + cls.options_flow, + cls.async_options_flow_finished, + cls.async_setup_preview, ) # Create an async_get_options_flow method @@ -285,6 +292,10 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): """Initialize config flow.""" self._common_handler = SchemaCommonFlowHandler(self, self.config_flow, None) + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + @classmethod @callback def async_supports_options_flow( @@ -336,7 +347,7 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): """ @callback - def async_create_entry( # pylint: disable=arguments-differ + def async_create_entry( self, data: Mapping[str, Any], **kwargs: Any, @@ -357,6 +368,8 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): options_flow: Mapping[str, SchemaFlowStep], async_options_flow_finished: Callable[[HomeAssistant, Mapping[str, Any]], None] | None = None, + async_setup_preview: Callable[[HomeAssistant], Coroutine[Any, Any, None]] + | None = None, ) -> None: """Initialize options flow. @@ -378,6 +391,9 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): types.MethodType(self._async_step(step), self), ) + if async_setup_preview: + setattr(self, "async_setup_preview", async_setup_preview) + @staticmethod def _async_step(step_id: str) -> Callable: """Generate a step handler.""" @@ -393,7 +409,7 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): return _async_step @callback - def async_create_entry( # pylint: disable=arguments-differ + def async_create_entry( self, data: Mapping[str, Any], **kwargs: Any, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 0dacb90e318..c9d8de23b96 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Mapping, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy +from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial import itertools @@ -13,7 +14,6 @@ import logging from types import MappingProxyType from typing import Any, TypedDict, TypeVar, cast -import async_timeout import voluptuous as vol from homeassistant import exceptions @@ -402,7 +402,7 @@ class _ScriptRun: ) self._log("Executing step %s%s", self._script.last_action, _timeout) - async def async_run(self) -> ServiceResponse: + async def async_run(self) -> ScriptRunResult | None: """Run script.""" # Push the script to the script execution stack if (script_stack := script_stack_cv.get()) is None: @@ -444,7 +444,7 @@ class _ScriptRun: script_stack.pop() self._finish() - return response + return ScriptRunResult(response, self._variables) async def _async_step(self, log_exceptions): continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) @@ -574,7 +574,7 @@ class _ScriptRun: self._changed() trace_set_result(delay=delay, done=False) try: - async with async_timeout.timeout(delay): + async with asyncio.timeout(delay): await self._stop.wait() except asyncio.TimeoutError: trace_set_result(delay=delay, done=True) @@ -602,9 +602,10 @@ class _ScriptRun: @callback def async_script_wait(entity_id, from_s, to_s): """Handle script after template condition is true.""" + # pylint: disable=protected-access wait_var = self._variables["wait"] - if to_context and to_context.deadline: - wait_var["remaining"] = to_context.deadline - self._hass.loop.time() + if to_context and to_context._when: + wait_var["remaining"] = to_context._when - self._hass.loop.time() else: wait_var["remaining"] = timeout wait_var["completed"] = True @@ -621,7 +622,7 @@ class _ScriptRun: self._hass.async_create_task(flag.wait()) for flag in (self._stop, done) ] try: - async with async_timeout.timeout(timeout) as to_context: + async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) except asyncio.TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 @@ -971,9 +972,10 @@ class _ScriptRun: done = asyncio.Event() async def async_done(variables, context=None): + # pylint: disable=protected-access wait_var = self._variables["wait"] - if to_context and to_context.deadline: - wait_var["remaining"] = to_context.deadline - self._hass.loop.time() + if to_context and to_context._when: + wait_var["remaining"] = to_context._when - self._hass.loop.time() else: wait_var["remaining"] = timeout wait_var["trigger"] = variables["trigger"] @@ -1000,7 +1002,7 @@ class _ScriptRun: self._hass.async_create_task(flag.wait()) for flag in (self._stop, done) ] try: - async with async_timeout.timeout(timeout) as to_context: + async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) except asyncio.TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 @@ -1188,6 +1190,14 @@ class _IfData(TypedDict): if_else: Script | None +@dataclass +class ScriptRunResult: + """Container with the result of a script run.""" + + service_response: ServiceResponse + variables: dict + + class Script: """Representation of a script.""" @@ -1479,7 +1489,7 @@ class Script: run_variables: _VarsType | None = None, context: Context | None = None, started_action: Callable[..., Any] | None = None, - ) -> ServiceResponse: + ) -> ScriptRunResult | None: """Run script.""" if context is None: self._log( diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 08975c5c881..efb1ee0b1f1 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -92,6 +92,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.fan import FanEntityFeature from homeassistant.components.humidifier import HumidifierEntityFeature + from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature @@ -100,6 +101,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.update import UpdateEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature + from homeassistant.components.weather import WeatherEntityFeature return { "AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature, @@ -109,6 +111,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "CoverEntityFeature": CoverEntityFeature, "FanEntityFeature": FanEntityFeature, "HumidifierEntityFeature": HumidifierEntityFeature, + "LawnMowerEntityFeature": LawnMowerEntityFeature, "LightEntityFeature": LightEntityFeature, "LockEntityFeature": LockEntityFeature, "MediaPlayerEntityFeature": MediaPlayerEntityFeature, @@ -117,6 +120,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "UpdateEntityFeature": UpdateEntityFeature, "VacuumEntityFeature": VacuumEntityFeature, "WaterHeaterEntityFeature": WaterHeaterEntityFeature, + "WeatherEntityFeature": WeatherEntityFeature, } @@ -988,6 +992,7 @@ class SelectSelectorConfig(TypedDict, total=False): custom_value: bool mode: SelectSelectorMode translation_key: str + sort: bool @SELECTORS.register("select") @@ -1005,6 +1010,7 @@ class SelectSelector(Selector[SelectSelectorConfig]): vol.Coerce(SelectSelectorMode), lambda val: val.value ), vol.Optional("translation_key"): cv.string, + vol.Optional("sort", default=False): cv.boolean, } ) diff --git a/homeassistant/helpers/sensor.py b/homeassistant/helpers/sensor.py index 96e6b83a167..0785a78850a 100644 --- a/homeassistant/helpers/sensor.py +++ b/homeassistant/helpers/sensor.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from homeassistant import const -from .entity import DeviceInfo +from .device_registry import DeviceInfo if TYPE_CHECKING: # `sensor_state_data` is a second-party library (i.e. maintained by Home Assistant diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 74823dea953..3eb537f9649 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -597,7 +597,7 @@ async def async_get_all_descriptions( ints_or_excs = await async_get_integrations(hass, missing) integrations: list[Integration] = [] for domain, int_or_exc in ints_or_excs.items(): - if type(int_or_exc) is Integration: # pylint: disable=unidiomatic-typecheck + if type(int_or_exc) is Integration: # noqa: E721 integrations.append(int_or_exc) continue if TYPE_CHECKING: diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index dd394c84f91..0e92cc6ff01 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -93,6 +93,7 @@ class Store(Generic[_T]): atomic_writes: bool = False, encoder: type[JSONEncoder] | None = None, minor_version: int = 1, + read_only: bool = False, ) -> None: """Initialize storage class.""" self.version = version @@ -107,6 +108,7 @@ class Store(Generic[_T]): self._load_task: asyncio.Future[_T | None] | None = None self._encoder = encoder self._atomic_writes = atomic_writes + self._read_only = read_only @property def path(self): @@ -235,7 +237,6 @@ class Store(Generic[_T]): self.minor_version, ) if len(inspect.signature(self._async_migrate_func).parameters) == 2: - # pylint: disable-next=no-value-for-parameter stored = await self._async_migrate_func(data["version"], data["data"]) else: try: @@ -344,6 +345,9 @@ class Store(Generic[_T]): self._data = None + if self._read_only: + return + try: await self._async_write_data(self.path, data) except (json_util.SerializationError, WriteError) as err: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d40a0289ab8..9f280db6c98 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -34,7 +34,6 @@ from typing import ( from urllib.parse import urlencode as urllib_urlencode import weakref -import async_timeout from awesomeversion import AwesomeVersion import jinja2 from jinja2 import pass_context, pass_environment, pass_eval_context @@ -459,6 +458,7 @@ class Template: "_exc_info", "_limited", "_strict", + "_log_fn", "_hash_cache", "_renders", ) @@ -476,6 +476,7 @@ class Template: self._exc_info: sys._OptExcInfo | None = None self._limited: bool | None = None self._strict: bool | None = None + self._log_fn: Callable[[int, str], None] | None = None self._hash_cache: int = hash(self.template) self._renders: int = 0 @@ -483,6 +484,11 @@ class Template: def _env(self) -> TemplateEnvironment: if self.hass is None: return _NO_HASS_ENV + # Bypass cache if a custom log function is specified + if self._log_fn is not None: + return TemplateEnvironment( + self.hass, self._limited, self._strict, self._log_fn + ) if self._limited: wanted_env = _ENVIRONMENT_LIMITED elif self._strict: @@ -492,9 +498,7 @@ class Template: ret: TemplateEnvironment | None = self.hass.data.get(wanted_env) if ret is None: ret = self.hass.data[wanted_env] = TemplateEnvironment( - self.hass, - self._limited, # type: ignore[no-untyped-call] - self._strict, + self.hass, self._limited, self._strict, self._log_fn ) return ret @@ -538,6 +542,7 @@ class Template: parse_result: bool = True, limited: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> Any: """Render given template. @@ -554,7 +559,7 @@ class Template: return self.template return self._parse_result(self.template) - compiled = self._compiled or self._ensure_compiled(limited, strict) + compiled = self._compiled or self._ensure_compiled(limited, strict, log_fn) if variables is not None: kwargs.update(variables) @@ -609,6 +614,7 @@ class Template: timeout: float, variables: TemplateVarsType = None, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> bool: """Check to see if rendering a template will timeout during render. @@ -629,7 +635,7 @@ class Template: if self.is_static: return False - compiled = self._compiled or self._ensure_compiled(strict=strict) + compiled = self._compiled or self._ensure_compiled(strict=strict, log_fn=log_fn) if variables is not None: kwargs.update(variables) @@ -651,7 +657,7 @@ class Template: try: template_render_thread = ThreadWithException(target=_render_template) template_render_thread.start() - async with async_timeout.timeout(timeout): + async with asyncio.timeout(timeout): await finish_event.wait() if self._exc_info: raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2])) @@ -665,7 +671,11 @@ class Template: @callback def async_render_to_info( - self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any + self, + variables: TemplateVarsType = None, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + **kwargs: Any, ) -> RenderInfo: """Render the template and collect an entity filter.""" self._renders += 1 @@ -681,7 +691,9 @@ class Template: token = _render_info.set(render_info) try: - render_info._result = self.async_render(variables, strict=strict, **kwargs) + render_info._result = self.async_render( + variables, strict=strict, log_fn=log_fn, **kwargs + ) except TemplateError as ex: render_info.exception = ex finally: @@ -744,7 +756,10 @@ class Template: return value if error_value is _SENTINEL else error_value def _ensure_compiled( - self, limited: bool = False, strict: bool = False + self, + limited: bool = False, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, ) -> jinja2.Template: """Bind a template to a specific hass instance.""" self.ensure_valid() @@ -757,10 +772,14 @@ class Template: self._strict is None or self._strict == strict ), "can't change between strict and non strict template" assert not (strict and limited), "can't combine strict and limited template" + assert ( + self._log_fn is None or self._log_fn == log_fn + ), "can't change custom log function" assert self._compiled_code is not None, "template code was not compiled" self._limited = limited self._strict = strict + self._log_fn = log_fn env = self._env self._compiled = jinja2.Template.from_code( @@ -1934,7 +1953,7 @@ def is_number(value): fvalue = float(value) except (ValueError, TypeError): return False - if math.isnan(fvalue) or math.isinf(fvalue): + if not math.isfinite(fvalue): return False return True @@ -2179,45 +2198,56 @@ def _render_with_context( return template.render(**kwargs) -class LoggingUndefined(jinja2.Undefined): +def make_logging_undefined( + strict: bool | None, log_fn: Callable[[int, str], None] | None +) -> type[jinja2.Undefined]: """Log on undefined variables.""" - def _log_message(self) -> None: + if strict: + return jinja2.StrictUndefined + + def _log_with_logger(level: int, msg: str) -> None: template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.warning( - "Template variable warning: %s when %s '%s'", - self._undefined_message, + _LOGGER.log( + level, + "Template variable %s: %s when %s '%s'", + logging.getLevelName(level).lower(), + msg, action, template, ) - def _fail_with_undefined_error(self, *args, **kwargs): - try: - return super()._fail_with_undefined_error(*args, **kwargs) - except self._undefined_exception as ex: - template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.error( - "Template variable error: %s when %s '%s'", - self._undefined_message, - action, - template, - ) - raise ex + _log_fn = log_fn or _log_with_logger - def __str__(self) -> str: - """Log undefined __str___.""" - self._log_message() - return super().__str__() + class LoggingUndefined(jinja2.Undefined): + """Log on undefined variables.""" - def __iter__(self): - """Log undefined __iter___.""" - self._log_message() - return super().__iter__() + def _log_message(self) -> None: + _log_fn(logging.WARNING, self._undefined_message) - def __bool__(self) -> bool: - """Log undefined __bool___.""" - self._log_message() - return super().__bool__() + def _fail_with_undefined_error(self, *args, **kwargs): + try: + return super()._fail_with_undefined_error(*args, **kwargs) + except self._undefined_exception as ex: + _log_fn(logging.ERROR, self._undefined_message) + raise ex + + def __str__(self) -> str: + """Log undefined __str___.""" + self._log_message() + return super().__str__() + + def __iter__(self): + """Log undefined __iter___.""" + self._log_message() + return super().__iter__() + + def __bool__(self) -> bool: + """Log undefined __bool___.""" + self._log_message() + return super().__bool__() + + return LoggingUndefined async def async_load_custom_templates(hass: HomeAssistant) -> None: @@ -2277,14 +2307,15 @@ class HassLoader(jinja2.BaseLoader): class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" - def __init__(self, hass, limited=False, strict=False): + def __init__( + self, + hass: HomeAssistant | None, + limited: bool | None = False, + strict: bool | None = False, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: """Initialise template environment.""" - undefined: type[LoggingUndefined] | type[jinja2.StrictUndefined] - if not strict: - undefined = LoggingUndefined - else: - undefined = jinja2.StrictUndefined - super().__init__(undefined=undefined) + super().__init__(undefined=make_logging_undefined(strict, log_fn)) self.hass = hass self.template_cache: weakref.WeakValueDictionary[ str | jinja2.nodes.Template, CodeType | str | None @@ -2382,6 +2413,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): # can be discarded, we only need to get at the hass object. def hassfunction( func: Callable[Concatenate[HomeAssistant, _P], _R], + jinja_context: Callable[ + [Callable[Concatenate[Any, _P], _R]], + Callable[Concatenate[Any, _P], _R], + ] = pass_context, ) -> Callable[Concatenate[Any, _P], _R]: """Wrap function that depend on hass.""" @@ -2389,42 +2424,40 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: return func(hass, *args, **kwargs) - return pass_context(wrapper) + return jinja_context(wrapper) self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = pass_context(self.globals["device_entities"]) + self.filters["device_entities"] = self.globals["device_entities"] self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = pass_context(self.globals["device_attr"]) + self.filters["device_attr"] = self.globals["device_attr"] self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = pass_eval_context(self.globals["is_device_attr"]) + self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = pass_context(self.globals["config_entry_id"]) + self.filters["config_entry_id"] = self.globals["config_entry_id"] self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.filters["device_id"] = self.globals["device_id"] self.globals["areas"] = hassfunction(areas) - self.filters["areas"] = pass_context(self.globals["areas"]) + self.filters["areas"] = self.globals["areas"] self.globals["area_id"] = hassfunction(area_id) - self.filters["area_id"] = pass_context(self.globals["area_id"]) + self.filters["area_id"] = self.globals["area_id"] self.globals["area_name"] = hassfunction(area_name) - self.filters["area_name"] = pass_context(self.globals["area_name"]) + self.filters["area_name"] = self.globals["area_name"] self.globals["area_entities"] = hassfunction(area_entities) - self.filters["area_entities"] = pass_context(self.globals["area_entities"]) + self.filters["area_entities"] = self.globals["area_entities"] self.globals["area_devices"] = hassfunction(area_devices) - self.filters["area_devices"] = pass_context(self.globals["area_devices"]) + self.filters["area_devices"] = self.globals["area_devices"] self.globals["integration_entities"] = hassfunction(integration_entities) - self.filters["integration_entities"] = pass_context( - self.globals["integration_entities"] - ) + self.filters["integration_entities"] = self.globals["integration_entities"] if limited: # Only device_entities is available to limited templates, mark other @@ -2480,25 +2513,25 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return self.globals["expand"] = hassfunction(expand) - self.filters["expand"] = pass_context(self.globals["expand"]) + self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = pass_context(hassfunction(closest_filter)) + self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_eval_context( - self.globals["is_hidden_entity"] + self.tests["is_hidden_entity"] = hassfunction( + is_hidden_entity, pass_eval_context ) self.globals["is_state"] = hassfunction(is_state) - self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) + self.tests["is_state"] = hassfunction(is_state, pass_eval_context) self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.tests["is_state_attr"] = pass_eval_context(self.globals["is_state_attr"]) + self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) self.globals["state_attr"] = hassfunction(state_attr) self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) self.filters["states"] = self.globals["states"] self.globals["has_value"] = hassfunction(has_value) - self.filters["has_value"] = pass_context(self.globals["has_value"]) - self.tests["has_value"] = pass_eval_context(self.globals["has_value"]) + self.filters["has_value"] = self.globals["has_value"] + self.tests["has_value"] = hassfunction(has_value, pass_eval_context) self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) @@ -2576,4 +2609,4 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return cached -_NO_HASS_ENV = TemplateEnvironment(None) # type: ignore[no-untyped-call] +_NO_HASS_ENV = TemplateEnvironment(None) diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py deleted file mode 100644 index 2e5cebf8571..00000000000 --- a/homeassistant/helpers/template_entity.py +++ /dev/null @@ -1,654 +0,0 @@ -"""TemplateEntity utility class.""" -from __future__ import annotations - -from collections.abc import Callable -import contextlib -import logging -from typing import Any - -import voluptuous as vol - -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorEntity, -) -from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - CONF_DEVICE_CLASS, - CONF_ICON, - CONF_NAME, - CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, - EVENT_HOMEASSISTANT_START, - STATE_UNKNOWN, -) -from homeassistant.core import Context, CoreState, HomeAssistant, State, callback -from homeassistant.exceptions import TemplateError -from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads - -from . import config_validation as cv -from .entity import Entity -from .event import ( - EventStateChangedData, - TrackTemplate, - TrackTemplateResult, - async_track_template_result, -) -from .script import Script, _VarsType -from .template import ( - Template, - TemplateStateFromEntityId, - attach as template_attach, - render_complex, - result_as_boolean, -) -from .typing import ConfigType, EventType - -_LOGGER = logging.getLogger(__name__) - -CONF_AVAILABILITY = "availability" -CONF_ATTRIBUTES = "attributes" -CONF_PICTURE = "picture" - -CONF_TO_ATTRIBUTE = { - CONF_ICON: ATTR_ICON, - CONF_NAME: ATTR_FRIENDLY_NAME, - CONF_PICTURE: ATTR_ENTITY_PICTURE, -} - -TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } -) - - -def make_template_entity_base_schema(default_name: str) -> vol.Schema: - """Return a schema with default name.""" - return vol.Schema( - { - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_NAME, default=default_name): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - - -TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) - - -class _TemplateAttribute: - """Attribute value linked to template result.""" - - def __init__( - self, - entity: Entity, - attribute: str, - template: Template, - validator: Callable[[Any], Any] | None = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool | None = False, - ) -> None: - """Template attribute.""" - self._entity = entity - self._attribute = attribute - self.template = template - self.validator = validator - self.on_update = on_update - self.async_update = None - self.none_on_template_error = none_on_template_error - - @callback - def async_setup(self) -> None: - """Config update path for the attribute.""" - if self.on_update: - return - - if not hasattr(self._entity, self._attribute): - raise AttributeError(f"Attribute '{self._attribute}' does not exist.") - - self.on_update = self._default_update - - @callback - def _default_update(self, result: str | TemplateError) -> None: - attr_result = None if isinstance(result, TemplateError) else result - setattr(self._entity, self._attribute, attr_result) - - @callback - def handle_result( - self, - event: EventType[EventStateChangedData] | None, - template: Template, - last_result: str | None | TemplateError, - result: str | TemplateError, - ) -> None: - """Handle a template result event callback.""" - if isinstance(result, TemplateError): - _LOGGER.error( - ( - "TemplateError('%s') " - "while processing template '%s' " - "for attribute '%s' in entity '%s'" - ), - result, - self.template, - self._attribute, - self._entity.entity_id, - ) - if self.none_on_template_error: - self._default_update(result) - else: - assert self.on_update - self.on_update(result) - return - - if not self.validator: - assert self.on_update - self.on_update(result) - return - - try: - validated = self.validator(result) - except vol.Invalid as ex: - _LOGGER.error( - ( - "Error validating template result '%s' " - "from template '%s' " - "for attribute '%s' in entity %s " - "validation message '%s'" - ), - result, - self.template, - self._attribute, - self._entity.entity_id, - ex.msg, - ) - assert self.on_update - self.on_update(None) - return - - assert self.on_update - self.on_update(validated) - return - - -class TemplateEntity(Entity): - """Entity that uses templates to calculate attributes.""" - - _attr_available = True - _attr_entity_picture = None - _attr_icon = None - - def __init__( - self, - hass: HomeAssistant, - *, - availability_template: Template | None = None, - icon_template: Template | None = None, - entity_picture_template: Template | None = None, - attribute_templates: dict[str, Template] | None = None, - config: ConfigType | None = None, - fallback_name: str | None = None, - unique_id: str | None = None, - ) -> None: - """Template Entity.""" - self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} - self._async_update: Callable[[], None] | None = None - self._attr_extra_state_attributes = {} - self._self_ref_update_count = 0 - self._attr_unique_id = unique_id - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - - class DummyState(State): - """None-state for template entities not yet added to the state machine.""" - - def __init__(self) -> None: - """Initialize a new state.""" - super().__init__("unknown.unknown", STATE_UNKNOWN) - self.entity_id = None # type: ignore[assignment] - - @property - def name(self) -> str: - """Name of this state.""" - return "" - - variables = {"this": DummyState()} - - # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name - if self._friendly_name_template: - self._friendly_name_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_name = self._friendly_name_template.async_render( - variables=variables, parse_result=False - ) - - # Templates will not render while the entity is unavailable, try to render the - # icon and picture templates. - if self._entity_picture_template: - self._entity_picture_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_entity_picture = self._entity_picture_template.async_render( - variables=variables, parse_result=False - ) - - if self._icon_template: - self._icon_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_icon = self._icon_template.async_render( - variables=variables, parse_result=False - ) - - @callback - def _update_available(self, result: str | TemplateError) -> None: - if isinstance(result, TemplateError): - self._attr_available = True - return - - self._attr_available = result_as_boolean(result) - - @callback - def _update_state(self, result: str | TemplateError) -> None: - if self._availability_template: - return - - self._attr_available = not isinstance(result, TemplateError) - - @callback - def _add_attribute_template( - self, attribute_key: str, attribute_template: Template - ) -> None: - """Create a template tracker for the attribute.""" - - def _update_attribute(result: str | TemplateError) -> None: - attr_result = None if isinstance(result, TemplateError) else result - self._attr_extra_state_attributes[attribute_key] = attr_result - - self.add_template_attribute( - attribute_key, attribute_template, None, _update_attribute - ) - - def add_template_attribute( - self, - attribute: str, - template: Template, - validator: Callable[[Any], Any] | None = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool = False, - ) -> None: - """Call in the constructor to add a template linked to a attribute. - - Parameters - ---------- - attribute - The name of the attribute to link to. This attribute must exist - unless a custom on_update method is supplied. - template - The template to calculate. - validator - Validator function to parse the result and ensure it's valid. - on_update - Called to store the template result rather than storing it - the supplied attribute. Passed the result of the validator, or None - if the template or validator resulted in an error. - none_on_template_error - If True, the attribute will be set to None if the template errors. - - """ - assert self.hass is not None, "hass cannot be None" - template.hass = self.hass - template_attribute = _TemplateAttribute( - self, attribute, template, validator, on_update, none_on_template_error - ) - self._template_attrs.setdefault(template, []) - self._template_attrs[template].append(template_attribute) - - @callback - def _handle_results( - self, - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - """Call back the results to the attributes.""" - if event: - self.async_set_context(event.context) - - entity_id = event and event.data["entity_id"] - - if entity_id and entity_id == self.entity_id: - self._self_ref_update_count += 1 - else: - self._self_ref_update_count = 0 - - if self._self_ref_update_count > len(self._template_attrs): - for update in updates: - _LOGGER.warning( - ( - "Template loop detected while processing event: %s, skipping" - " template render for Template[%s]" - ), - event, - update.template.template, - ) - return - - for update in updates: - for attr in self._template_attrs[update.template]: - attr.handle_result( - event, update.template, update.last_result, update.result - ) - - self.async_write_ha_state() - - async def _async_template_startup(self, *_: Any) -> None: - template_var_tups: list[TrackTemplate] = [] - has_availability_template = False - - variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} - - for template, attributes in self._template_attrs.items(): - template_var_tup = TrackTemplate(template, variables) - is_availability_template = False - for attribute in attributes: - # pylint: disable-next=protected-access - if attribute._attribute == "_attr_available": - has_availability_template = True - is_availability_template = True - attribute.async_setup() - # Insert the availability template first in the list - if is_availability_template: - template_var_tups.insert(0, template_var_tup) - else: - template_var_tups.append(template_var_tup) - - result_info = async_track_template_result( - self.hass, - template_var_tups, - self._handle_results, - has_super_template=has_availability_template, - ) - self.async_on_remove(result_info.async_remove) - self._async_update = result_info.async_refresh - result_info.async_refresh() - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - if self._availability_template is not None: - self.add_template_attribute( - "_attr_available", - self._availability_template, - None, - self._update_available, - ) - if self._attribute_templates is not None: - for key, value in self._attribute_templates.items(): - self._add_attribute_template(key, value) - if self._icon_template is not None: - self.add_template_attribute( - "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) - ) - if self._entity_picture_template is not None: - self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template - ) - if ( - self._friendly_name_template is not None - and not self._friendly_name_template.is_static - ): - self.add_template_attribute("_attr_name", self._friendly_name_template) - - if self.hass.state == CoreState.running: - await self._async_template_startup() - return - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_template_startup - ) - - async def async_update(self) -> None: - """Call for forced update.""" - assert self._async_update - self._async_update() - - async def async_run_script( - self, - script: Script, - *, - run_variables: _VarsType | None = None, - context: Context | None = None, - ) -> None: - """Run an action script.""" - if run_variables is None: - run_variables = {} - await script.async_run( - run_variables={ - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **run_variables, - }, - context=context, - ) - - -class TemplateSensor(TemplateEntity, SensorEntity): - """Representation of a Template Sensor.""" - - def __init__( - self, - hass: HomeAssistant, - *, - config: dict[str, Any], - fallback_name: str | None, - unique_id: str | None, - ) -> None: - """Initialize the sensor.""" - super().__init__( - hass, config=config, fallback_name=fallback_name, unique_id=unique_id - ) - - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_state_class = config.get(CONF_STATE_CLASS) - - -class TriggerBaseEntity(Entity): - """Template Base entity based on trigger data.""" - - domain: str - extra_template_keys: tuple | None = None - extra_template_keys_complex: tuple | None = None - _unique_id: str | None - - def __init__( - self, - hass: HomeAssistant, - config: dict, - ) -> None: - """Initialize the entity.""" - self.hass = hass - - self._set_unique_id(config.get(CONF_UNIQUE_ID)) - - self._config = config - - self._static_rendered = {} - self._to_render_simple = [] - self._to_render_complex: list[str] = [] - - for itm in ( - CONF_AVAILABILITY, - CONF_ICON, - CONF_NAME, - CONF_PICTURE, - ): - if itm not in config or config[itm] is None: - continue - if config[itm].is_static: - self._static_rendered[itm] = config[itm].template - else: - self._to_render_simple.append(itm) - - if self.extra_template_keys is not None: - self._to_render_simple.extend(self.extra_template_keys) - - if self.extra_template_keys_complex is not None: - self._to_render_complex.extend(self.extra_template_keys_complex) - - # We make a copy so our initial render is 'unknown' and not 'unavailable' - self._rendered = dict(self._static_rendered) - self._parse_result = {CONF_AVAILABILITY} - - @property - def name(self) -> str | None: - """Name of the entity.""" - return self._rendered.get(CONF_NAME) - - @property - def unique_id(self) -> str | None: - """Return unique ID of the entity.""" - return self._unique_id - - @property - def device_class(self): # type: ignore[no-untyped-def] - """Return device class of the entity.""" - return self._config.get(CONF_DEVICE_CLASS) - - @property - def icon(self) -> str | None: - """Return icon.""" - return self._rendered.get(CONF_ICON) - - @property - def entity_picture(self) -> str | None: - """Return entity picture.""" - return self._rendered.get(CONF_PICTURE) - - @property - def available(self) -> bool: - """Return availability of the entity.""" - return ( - self._rendered is not self._static_rendered - and - # Check against False so `None` is ok - self._rendered.get(CONF_AVAILABILITY) is not False - ) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return extra attributes.""" - return self._rendered.get(CONF_ATTRIBUTES) - - async def async_added_to_hass(self) -> None: - """Handle being added to Home Assistant.""" - template_attach(self.hass, self._config) - - def _set_unique_id(self, unique_id: str | None) -> None: - """Set unique id.""" - self._unique_id = unique_id - - def restore_attributes(self, last_state: State) -> None: - """Restore attributes.""" - for conf_key, attr in CONF_TO_ATTRIBUTE.items(): - if conf_key not in self._config or attr not in last_state.attributes: - continue - self._rendered[conf_key] = last_state.attributes[attr] - - if CONF_ATTRIBUTES in self._config: - extra_state_attributes = {} - for attr in self._config[CONF_ATTRIBUTES]: - if attr not in last_state.attributes: - continue - extra_state_attributes[attr] = last_state.attributes[attr] - self._rendered[CONF_ATTRIBUTES] = extra_state_attributes - - def _render_templates(self, variables: dict[str, Any]) -> None: - """Render templates.""" - try: - rendered = dict(self._static_rendered) - - for key in self._to_render_simple: - rendered[key] = self._config[key].async_render( - variables, - parse_result=key in self._parse_result, - ) - - for key in self._to_render_complex: - rendered[key] = render_complex( - self._config[key], - variables, - ) - - if CONF_ATTRIBUTES in self._config: - rendered[CONF_ATTRIBUTES] = render_complex( - self._config[CONF_ATTRIBUTES], - variables, - ) - - self._rendered = rendered - except TemplateError as err: - logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( - "Error rendering %s template for %s: %s", key, self.entity_id, err - ) - self._rendered = self._static_rendered - - -class ManualTriggerEntity(TriggerBaseEntity): - """Template entity based on manual trigger data.""" - - def __init__( - self, - hass: HomeAssistant, - config: dict, - ) -> None: - """Initialize the entity.""" - TriggerBaseEntity.__init__(self, hass, config) - # Need initial rendering on `name` as it influence the `entity_id` - self._rendered[CONF_NAME] = config[CONF_NAME].async_render( - {}, - parse_result=CONF_NAME in self._parse_result, - ) - - @callback - def _process_manual_data(self, value: Any | None = None) -> None: - """Process new data manually. - - Implementing class should call this last in update method to render templates. - Ex: self._process_manual_data(payload) - """ - - self.async_write_ha_state() - this = None - if state := self.hass.states.get(self.entity_id): - this = state.as_dict() - - run_variables: dict[str, Any] = {"value": value} - # Silently try if variable is a json and store result in `value_json` if it is. - with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): - run_variables["value_json"] = json_loads(run_variables["value"]) - variables = {"this": this, **(run_variables or {})} - - self._render_templates(variables) diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py new file mode 100644 index 00000000000..0ee653b42bd --- /dev/null +++ b/homeassistant/helpers/trigger_template_entity.py @@ -0,0 +1,267 @@ +"""TemplateEntity utility class.""" +from __future__ import annotations + +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA, + SensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import TemplateError +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads + +from . import config_validation as cv +from .entity import Entity +from .template import attach as template_attach, render_complex +from .typing import ConfigType + +CONF_AVAILABILITY = "availability" +CONF_ATTRIBUTES = "attributes" +CONF_PICTURE = "picture" + +CONF_TO_ATTRIBUTE = { + CONF_ICON: ATTR_ICON, + CONF_NAME: ATTR_FRIENDLY_NAME, + CONF_PICTURE: ATTR_ENTITY_PICTURE, +} + +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + + +def make_template_entity_base_schema(default_name: str) -> vol.Schema: + """Return a schema with default name.""" + return vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME, default=default_name): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } + ) + + +TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) + + +class TriggerBaseEntity(Entity): + """Template Base entity based on trigger data.""" + + domain: str + extra_template_keys: tuple[str, ...] | None = None + extra_template_keys_complex: tuple[str, ...] | None = None + _unique_id: str | None + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + self.hass = hass + + self._set_unique_id(config.get(CONF_UNIQUE_ID)) + + self._config = config + + self._static_rendered = {} + self._to_render_simple: list[str] = [] + self._to_render_complex: list[str] = [] + + for itm in ( + CONF_AVAILABILITY, + CONF_ICON, + CONF_NAME, + CONF_PICTURE, + ): + if itm not in config or config[itm] is None: + continue + if config[itm].is_static: + self._static_rendered[itm] = config[itm].template + else: + self._to_render_simple.append(itm) + + if self.extra_template_keys is not None: + self._to_render_simple.extend(self.extra_template_keys) + + if self.extra_template_keys_complex is not None: + self._to_render_complex.extend(self.extra_template_keys_complex) + + # We make a copy so our initial render is 'unknown' and not 'unavailable' + self._rendered = dict(self._static_rendered) + self._parse_result = {CONF_AVAILABILITY} + + @property + def name(self) -> str | None: + """Name of the entity.""" + return self._rendered.get(CONF_NAME) + + @property + def unique_id(self) -> str | None: + """Return unique ID of the entity.""" + return self._unique_id + + @property + def device_class(self): # type: ignore[no-untyped-def] + """Return device class of the entity.""" + return self._config.get(CONF_DEVICE_CLASS) + + @property + def icon(self) -> str | None: + """Return icon.""" + return self._rendered.get(CONF_ICON) + + @property + def entity_picture(self) -> str | None: + """Return entity picture.""" + return self._rendered.get(CONF_PICTURE) + + @property + def available(self) -> bool: + """Return availability of the entity.""" + return ( + self._rendered is not self._static_rendered + and + # Check against False so `None` is ok + self._rendered.get(CONF_AVAILABILITY) is not False + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return self._rendered.get(CONF_ATTRIBUTES) + + async def async_added_to_hass(self) -> None: + """Handle being added to Home Assistant.""" + await super().async_added_to_hass() + template_attach(self.hass, self._config) + + def _set_unique_id(self, unique_id: str | None) -> None: + """Set unique id.""" + self._unique_id = unique_id + + def restore_attributes(self, last_state: State) -> None: + """Restore attributes.""" + for conf_key, attr in CONF_TO_ATTRIBUTE.items(): + if conf_key not in self._config or attr not in last_state.attributes: + continue + self._rendered[conf_key] = last_state.attributes[attr] + + if CONF_ATTRIBUTES in self._config: + extra_state_attributes = {} + for attr in self._config[CONF_ATTRIBUTES]: + if attr not in last_state.attributes: + continue + extra_state_attributes[attr] = last_state.attributes[attr] + self._rendered[CONF_ATTRIBUTES] = extra_state_attributes + + def _render_templates(self, variables: dict[str, Any]) -> None: + """Render templates.""" + try: + rendered = dict(self._static_rendered) + + for key in self._to_render_simple: + rendered[key] = self._config[key].async_render( + variables, + parse_result=key in self._parse_result, + ) + + for key in self._to_render_complex: + rendered[key] = render_complex( + self._config[key], + variables, + ) + + if CONF_ATTRIBUTES in self._config: + rendered[CONF_ATTRIBUTES] = render_complex( + self._config[CONF_ATTRIBUTES], + variables, + ) + + self._rendered = rendered + except TemplateError as err: + logging.getLogger(f"{__package__}.{self.entity_id.split('.')[0]}").error( + "Error rendering %s template for %s: %s", key, self.entity_id, err + ) + self._rendered = self._static_rendered + + +class ManualTriggerEntity(TriggerBaseEntity): + """Template entity based on manual trigger data.""" + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerBaseEntity.__init__(self, hass, config) + # Need initial rendering on `name` as it influence the `entity_id` + self._rendered[CONF_NAME] = config[CONF_NAME].async_render( + {}, + parse_result=CONF_NAME in self._parse_result, + ) + + @callback + def _process_manual_data(self, value: Any | None = None) -> None: + """Process new data manually. + + Implementing class should call this last in update method to render templates. + Ex: self._process_manual_data(payload) + """ + + self.async_write_ha_state() + this = None + if state := self.hass.states.get(self.entity_id): + this = state.as_dict() + + run_variables: dict[str, Any] = {"value": value} + # Silently try if variable is a json and store result in `value_json` if it is. + with contextlib.suppress(*JSON_DECODE_EXCEPTIONS): + run_variables["value_json"] = json_loads(run_variables["value"]) + variables = {"this": this, **(run_variables or {})} + + self._render_templates(variables) + + +class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): + """Template entity based on manual trigger data for sensor.""" + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + ) -> None: + """Initialize the sensor entity.""" + ManualTriggerEntity.__init__(self, hass, config) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_state_class = config.get(CONF_STATE_CLASS) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 36dd7d27d4a..34651fcaf9d 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -54,7 +54,12 @@ class BaseDataUpdateCoordinatorProtocol(Protocol): class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): - """Class to manage fetching data from single endpoint.""" + """Class to manage fetching data from single endpoint. + + Setting :attr:`always_update` to ``False`` will cause coordinator to only + callback listeners when data has changed. This requires that the data + implements ``__eq__`` or uses a python object that already does. + """ def __init__( self, @@ -65,6 +70,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): update_interval: timedelta | None = None, update_method: Callable[[], Awaitable[_DataT]] | None = None, request_refresh_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, + always_update: bool = True, ) -> None: """Initialize global data updater.""" self.hass = hass @@ -74,6 +80,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.update_interval = update_interval self._shutdown_requested = False self.config_entry = config_entries.current_entry.get() + self.always_update = always_update + self._next_refresh: float | None = None # It's None before the first successful update. # Components should call async_config_entry_first_refresh @@ -82,10 +90,11 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): # when it was already checked during setup. self.data: _DataT = None # type: ignore[assignment] - # Pick a random microsecond to stagger the refreshes + # Pick a random microsecond in range 0.05..0.50 to stagger the refreshes # and avoid a thundering herd. - self._microsecond = randint( - event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX + self._microsecond = ( + randint(event.RANDOM_MICROSECOND_MIN, event.RANDOM_MICROSECOND_MAX) + / 10**6 ) self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} @@ -175,6 +184,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): """Unschedule any pending refresh since there is no longer any listeners.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() + self._next_refresh = None def async_contexts(self) -> Generator[Any, None, None]: """Return all registered contexts.""" @@ -207,20 +217,16 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): # than the debouncer cooldown, this would cause the debounce to never be called self._async_unsub_refresh() - # We _floor_ utcnow to create a schedule on a rounded second, - # minimizing the time between the point and the real activation. - # That way we obtain a constant update frequency, - # as long as the update process takes less than 500ms - # - # We do not align everything to happen at microsecond 0 - # since it increases the risk of a thundering herd - # when multiple coordinators are scheduled to update at the same time. - # - # https://github.com/home-assistant/core/issues/82231 - self._unsub_refresh = event.async_track_point_in_utc_time( + # We use event.async_call_at because DataUpdateCoordinator does + # not need an exact update interval. + now = self.hass.loop.time() + if self._next_refresh is None or self._next_refresh <= now: + self._next_refresh = int(now) + self._microsecond + self._next_refresh += self.update_interval.total_seconds() + self._unsub_refresh = event.async_call_at( self.hass, self._job, - utcnow().replace(microsecond=self._microsecond) + self.update_interval, + self._next_refresh, ) async def _handle_refresh_interval(self, _now: datetime) -> None: @@ -259,6 +265,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): async def async_refresh(self) -> None: """Refresh data and log errors.""" + self._next_refresh = None await self._async_refresh(log_failures=True) async def _async_refresh( # noqa: C901 @@ -277,7 +284,10 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): if log_timing := self.logger.isEnabledFor(logging.DEBUG): start = monotonic() + auth_failed = False + previous_update_success = self.last_update_success + previous_data = self.data try: self.data = await self._async_update_data() @@ -371,7 +381,15 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): if not auth_failed and self._listeners and not self.hass.is_stopping: self._schedule_refresh() - self.async_update_listeners() + if not self.last_update_success and not previous_update_success: + return + + if ( + self.always_update + or self.last_update_success != previous_update_success + or previous_data != self.data + ): + self.async_update_listeners() @callback def async_set_update_error(self, err: Exception) -> None: @@ -387,6 +405,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): """Manually update data, notify listeners and reset refresh interval.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() + self._next_refresh = None self.data = data self.last_update_success = True @@ -401,6 +420,29 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.async_update_listeners() +class TimestampDataUpdateCoordinator(DataUpdateCoordinator[_DataT]): + """DataUpdateCoordinator which keeps track of the last successful update.""" + + last_update_success_time: datetime | None = None + + 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.""" + await super()._async_refresh( + log_failures, + raise_on_auth_failed, + scheduled, + raise_on_entry_error, + ) + if self.last_update_success: + self.last_update_success_time = utcnow() + + class BaseCoordinatorEntity(entity.Entity, Generic[_BaseDataUpdateCoordinatorT]): """Base class for all Coordinator entities.""" diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6c083b6a024..37e470c1178 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -166,6 +166,13 @@ class Manifest(TypedDict, total=False): loggers: list[str] +def async_setup(hass: HomeAssistant) -> None: + """Set up the necessary data structures.""" + _async_mount_config_dir(hass) + hass.data[DATA_COMPONENTS] = {} + hass.data[DATA_INTEGRATIONS] = {} + + def manifest_from_legacy_module(domain: str, module: ModuleType) -> Manifest: """Generate a manifest from a legacy module.""" return { @@ -802,9 +809,7 @@ class Integration: def get_component(self) -> ComponentProtocol: """Return the component.""" - cache: dict[str, ComponentProtocol] = self.hass.data.setdefault( - DATA_COMPONENTS, {} - ) + cache: dict[str, ComponentProtocol] = self.hass.data[DATA_COMPONENTS] if self.domain in cache: return cache[self.domain] @@ -824,7 +829,7 @@ class Integration: def get_platform(self, platform_name: str) -> ModuleType: """Return a platform for an integration.""" - cache: dict[str, ModuleType] = self.hass.data.setdefault(DATA_COMPONENTS, {}) + cache: dict[str, ModuleType] = self.hass.data[DATA_COMPONENTS] full_name = f"{self.domain}.{platform_name}" if full_name in cache: return cache[full_name] @@ -883,11 +888,7 @@ async def async_get_integrations( hass: HomeAssistant, domains: Iterable[str] ) -> dict[str, Integration | Exception]: """Get integrations.""" - if (cache := hass.data.get(DATA_INTEGRATIONS)) is None: - if not _async_mount_config_dir(hass): - return {domain: IntegrationNotFound(domain) for domain in domains} - cache = hass.data[DATA_INTEGRATIONS] = {} - + cache = hass.data[DATA_INTEGRATIONS] results: dict[str, Integration | Exception] = {} needed: dict[str, asyncio.Future[None]] = {} in_progress: dict[str, asyncio.Future[None]] = {} @@ -896,7 +897,7 @@ async def async_get_integrations( for domain in domains: int_or_fut = cache.get(domain, _UNDEF) # Integration is never subclassed, so we can check for type - if type(int_or_fut) is Integration: # pylint: disable=unidiomatic-typecheck + if type(int_or_fut) is Integration: # noqa: E721 results[domain] = int_or_fut elif int_or_fut is not _UNDEF: in_progress[domain] = cast(asyncio.Future[None], int_or_fut) @@ -993,10 +994,7 @@ def _load_file( comp_or_platform ] - if (cache := hass.data.get(DATA_COMPONENTS)) is None: - if not _async_mount_config_dir(hass): - return None - cache = hass.data[DATA_COMPONENTS] = {} + cache = hass.data[DATA_COMPONENTS] for path in (f"{base}.{comp_or_platform}" for base in base_paths): try: @@ -1066,7 +1064,7 @@ class Components: def __getattr__(self, comp_name: str) -> ModuleWrapper: """Fetch a component.""" # Test integration cache - integration = self._hass.data.get(DATA_INTEGRATIONS, {}).get(comp_name) + integration = self._hass.data[DATA_INTEGRATIONS].get(comp_name) if isinstance(integration, Integration): component: ComponentProtocol | None = integration.get_component() @@ -1150,17 +1148,13 @@ async def _async_component_dependencies( return loaded -def _async_mount_config_dir(hass: HomeAssistant) -> bool: +def _async_mount_config_dir(hass: HomeAssistant) -> None: """Mount config dir in order to load custom_component. Async friendly but not a coroutine. """ - if hass.config.config_dir is None: - _LOGGER.error("Can't load integrations - configuration directory is not set") - return False if hass.config.config_dir not in sys.path: sys.path.insert(0, hass.config.config_dir) - return True def _lookup_path(hass: HomeAssistant) -> list[str]: @@ -1168,3 +1162,8 @@ def _lookup_path(hass: HomeAssistant) -> list[str]: if hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] + + +def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: + """Test if a component module is loaded.""" + return module in hass.data[DATA_COMPONENTS] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d21007ae08b..7c48166172f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,27 +2,27 @@ aiodiscover==1.4.16 aiohttp-cors==0.7.0 aiohttp==3.8.5 astral==2.2 -async-timeout==4.0.2 -async-upnp-client==0.34.1 +async-timeout==4.0.3 +async-upnp-client==0.35.0 atomicwrites-homeassistant==1.4.1 -attrs==22.2.0 +attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.1.1 -bleak==0.20.2 +bleak-retry-connector==3.1.2 +bleak==0.21.0 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 -bluetooth-data-tools==1.6.1 +bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.90.1 -fnv-hash-fast==0.4.0 +dbus-fast==1.94.1 +fnv-hash-fast==0.4.1 ha-av==10.1.1 -hass-nabucasa==0.69.0 +hass-nabucasa==0.70.0 hassil==1.2.5 -home-assistant-bluetooth==1.10.2 -home-assistant-frontend==20230802.1 +home-assistant-bluetooth==1.10.3 +home-assistant-frontend==20230906.1 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 @@ -31,6 +31,7 @@ Jinja2==3.1.2 lru-dict==1.2.0 mutagen==1.46.0 orjson==3.9.2 +packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.0.0 pip>=21.3.1 @@ -47,12 +48,12 @@ requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.15 typing-extensions>=4.7.0,<5.0 -ulid-transform==0.8.0 +ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.74.0 +zeroconf==0.91.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -147,7 +148,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.23.3 +protobuf==4.24.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 30c5d0a2448..954de3bf5a6 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -7,7 +7,7 @@ import logging import os from typing import Any, cast -import pkg_resources +from packaging.requirements import Requirement from .core import HomeAssistant, callback from .exceptions import HomeAssistantError @@ -232,8 +232,7 @@ class RequirementsManager: skipped_requirements = [ req for req in requirements - if pkg_resources.Requirement.parse(req).project_name - in self.hass.config.skip_pip_packages + if Requirement(req).name in self.hass.config.skip_pip_packages ] for req in skipped_requirements: diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 67ec232db9c..ed49db37f97 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -8,9 +8,12 @@ import logging import os import subprocess import threading +from time import monotonic import traceback from typing import Any +import packaging.tags + from . import bootstrap from .core import callback from .helpers.frame import warn_use @@ -29,7 +32,6 @@ from .util.thread import deadlock_safe_shutdown # MAX_EXECUTOR_WORKERS = 64 TASK_CANCELATION_TIMEOUT = 5 -ALPINE_RELEASE_FILE = "/etc/alpine-release" _LOGGER = logging.getLogger(__name__) @@ -113,6 +115,10 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): loop.set_default_executor = warn_use( # type: ignore[method-assign] loop.set_default_executor, "sets default executor on the event loop" ) + # bind the built-in time.monotonic directly as loop.time to avoid the + # overhead of the additional method call since its the most called loop + # method and its roughly 10%+ of all the call time in base_events.py + loop.time = monotonic # type: ignore[method-assign] return loop @@ -164,8 +170,9 @@ def _enable_posix_spawn() -> None: # The subprocess module does not know about Alpine Linux/musl # and will use fork() instead of posix_spawn() which significantly # less efficient. This is a workaround to force posix_spawn() - # on Alpine Linux which is supported by musl. - subprocess._USE_POSIX_SPAWN = os.path.exists(ALPINE_RELEASE_FILE) + # when using musl since cpython is not aware its supported. + tag = next(packaging.tags.sys_tags()) + subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform def run(runtime_config: RuntimeConfig) -> int: diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py index 11ab6aadfbf..5714e5814a4 100644 --- a/homeassistant/scripts/auth.py +++ b/homeassistant/scripts/auth.py @@ -50,8 +50,7 @@ def run(args): async def run_command(args): """Run the command.""" - hass = HomeAssistant() - hass.config.config_dir = os.path.join(os.getcwd(), args.config) + hass = HomeAssistant(os.path.join(os.getcwd(), args.config)) 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/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 3627e4096d3..a04493a8935 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -49,7 +49,7 @@ def run(args): async def run_benchmark(bench): """Run a benchmark.""" - hass = core.HomeAssistant() + hass = core.HomeAssistant("") runtime = await bench(hass) print(f"Benchmark {bench.__name__} done in {runtime}s") await hass.async_stop() diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 5384b86cb98..38fa9cc2463 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -11,7 +11,7 @@ import os from typing import Any from unittest.mock import patch -from homeassistant import core +from homeassistant import core, loader from homeassistant.config import get_default_config_dir from homeassistant.config_entries import ConfigEntries from homeassistant.exceptions import HomeAssistantError @@ -19,6 +19,7 @@ from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file from homeassistant.util.yaml import Secrets @@ -231,12 +232,13 @@ def check(config_dir, secrets=False): async def async_check_config(config_dir): """Check the HA config.""" - hass = core.HomeAssistant() - hass.config.config_dir = config_dir + hass = core.HomeAssistant(config_dir) + loader.async_setup(hass) hass.config_entries = ConfigEntries(hass, {}) await ar.async_load(hass) await dr.async_load(hass) await er.async_load(hass) + await ir.async_load(hass, read_only=True) components = await async_check_ha_config_file(hass) await hass.async_stop(force=True) return components diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py index 6dbda59522f..786b16ca923 100644 --- a/homeassistant/scripts/ensure_config.py +++ b/homeassistant/scripts/ensure_config.py @@ -39,8 +39,7 @@ def run(args): async def async_run(config_dir): """Make sure config exists.""" - hass = HomeAssistant() - hass.config.config_dir = config_dir + hass = HomeAssistant(config_dir) path = await config_util.async_ensure_config_exists(hass) await hass.async_stop(force=True) return path diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 4caf074b879..ce1105cff75 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -21,9 +21,7 @@ _P = ParamSpec("_P") def cancelling(task: Future[Any]) -> bool: - """Return True if task is done or cancelling.""" - # https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancelling - # is new in Python 3.11 + """Return True if task is cancelling.""" return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 6ccb7f14ea2..d9f2a4b96ff 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -180,8 +180,8 @@ COLORS = { class XYPoint: """Represents a CIE 1931 XY coordinate pair.""" - x: float = attr.ib() # pylint: disable=invalid-name - y: float = attr.ib() # pylint: disable=invalid-name + x: float = attr.ib() + y: float = attr.ib() @attr.s() @@ -205,9 +205,6 @@ def color_name_to_rgb(color_name: str) -> RGBColor: return hex_value -# pylint: disable=invalid-name - - def color_RGB_to_xy( iR: int, iG: int, iB: int, Gamut: GamutType | None = None ) -> tuple[float, float]: diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py index 509760fff19..45b105aea9f 100644 --- a/homeassistant/util/distance.py +++ b/homeassistant/util/distance.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 LENGTH, LENGTH_CENTIMETERS, diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 4f49ec44ca7..34a81728d14 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -14,8 +14,8 @@ import zoneinfo import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" -UTC = dt.timezone.utc -DEFAULT_TIME_ZONE: dt.tzinfo = dt.timezone.utc +UTC = dt.UTC +DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 724c3ebf8d3..7f81c281340 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -11,7 +11,7 @@ import orjson from homeassistant.exceptions import HomeAssistantError -from .file import WriteError # pylint: disable=unused-import # noqa: F401 +from .file import WriteError # noqa: F401 _SENTINEL = object() _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def json_loads_array(__obj: bytes | bytearray | memoryview | str) -> JsonArrayTy """Parse JSON data and ensure result is a list.""" value: JsonValueType = json_loads(__obj) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # pylint: disable=unidiomatic-typecheck + if type(value) is list: # noqa: E721 return value raise ValueError(f"Expected JSON to be parsed as a list got {type(value)}") @@ -51,7 +51,7 @@ def json_loads_object(__obj: bytes | bytearray | memoryview | str) -> JsonObject """Parse JSON data and ensure result is a dictionary.""" value: JsonValueType = json_loads(__obj) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # pylint: disable=unidiomatic-typecheck + if type(value) is dict: # noqa: E721 return value raise ValueError(f"Expected JSON to be parsed as a dict got {type(value)}") @@ -89,7 +89,7 @@ def load_json_array( default = [] value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in list subclasses - if type(value) is list: # pylint: disable=unidiomatic-typecheck + if type(value) is list: # noqa: E721 return value _LOGGER.exception( "Expected JSON to be parsed as a list got %s in: %s", {type(value)}, filename @@ -108,7 +108,7 @@ def load_json_object( default = {} value: JsonValueType = load_json(filename, default=default) # Avoid isinstance overhead as we are not interested in dict subclasses - if type(value) is dict: # pylint: disable=unidiomatic-typecheck + if type(value) is dict: # noqa: E721 return value _LOGGER.exception( "Expected JSON to be parsed as a dict got %s in: %s", {type(value)}, filename diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index a251aec268e..44fcaa07067 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -89,7 +89,6 @@ def vincenty( if point1[0] == point2[0] and point1[1] == point2[1]: return 0.0 - # pylint: disable=invalid-name U1 = math.atan((1 - FLATTENING) * math.tan(math.radians(point1[0]))) U2 = math.atan((1 - FLATTENING) * math.tan(math.radians(point2[0]))) L = math.radians(point2[1] - point1[1]) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 45ceb471fd8..7de75c1e24f 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, version +from importlib.metadata import PackageNotFoundError, distribution, version import logging import os from pathlib import Path @@ -11,7 +11,7 @@ from subprocess import PIPE, Popen import sys from urllib.parse import urlparse -import pkg_resources +from packaging.requirements import InvalidRequirement, Requirement _LOGGER = logging.getLogger(__name__) @@ -37,26 +37,27 @@ def is_installed(package: str) -> bool: Returns False when the package is not installed or doesn't meet req. """ try: - pkg_resources.get_distribution(package) + distribution(package) return True - except (IndexError, pkg_resources.ResolutionError, pkg_resources.ExtractionError): - req = pkg_resources.Requirement.parse(package) - except ValueError: - # This is a zip file. We no longer use this in Home Assistant, - # leaving it in for custom components. - req = pkg_resources.Requirement.parse(urlparse(package).fragment) + except (IndexError, PackageNotFoundError): + try: + req = Requirement(package) + 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) try: - installed_version = version(req.project_name) + 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: _LOGGER.error( # type: ignore[unreachable] - "Installed version for %s resolved to None", req.project_name + "Installed version for %s resolved to None", req.name ) return False - return installed_version in req + return req.specifier.contains(installed_version, prereleases=True) except PackageNotFoundError: return False diff --git a/homeassistant/util/pil.py b/homeassistant/util/pil.py index 068b807cbe5..58dd48dec5e 100644 --- a/homeassistant/util/pil.py +++ b/homeassistant/util/pil.py @@ -4,7 +4,7 @@ Can only be used by integrations that have pillow in their requirements. """ from __future__ import annotations -from PIL import ImageDraw +from PIL.ImageDraw import ImageDraw def draw_box( diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py index 78a69e15a34..9c5082e95ed 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -1,7 +1,7 @@ """Pressure util functions.""" from __future__ import annotations -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 PRESSURE, PRESSURE_BAR, diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py index a1b6e0a7227..80a3609ab4d 100644 --- a/homeassistant/util/speed.py +++ b/homeassistant/util/speed.py @@ -1,7 +1,7 @@ """Distance util functions.""" from __future__ import annotations -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 SPEED, SPEED_FEET_PER_SECOND, @@ -16,7 +16,7 @@ from homeassistant.const import ( # noqa: F401 ) from homeassistant.helpers.frame import report -from .unit_conversion import ( # pylint: disable=unused-import # noqa: F401 +from .unit_conversion import ( # noqa: F401 _FOOT_TO_M as FOOT_TO_M, _HRS_TO_SECS as HRS_TO_SECS, _IN_TO_M as IN_TO_M, diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 84585d7a8c7..2b503716063 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -127,14 +127,9 @@ def server_context_modern() -> ssl.SSLContext: Modern guidelines are followed. """ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 - context.options |= ( - ssl.OP_NO_SSLv2 - | ssl.OP_NO_SSLv3 - | ssl.OP_NO_TLSv1 - | ssl.OP_NO_TLSv1_1 - | ssl.OP_CIPHER_SERVER_PREFERENCE - ) + context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE if hasattr(ssl, "OP_NO_COMPRESSION"): context.options |= ssl.OP_NO_COMPRESSION diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index 409fecd1090..74d56e84d94 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -1,5 +1,5 @@ """Temperature util functions.""" -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 TEMP_CELSIUS, TEMP_FAHRENHEIT, diff --git a/homeassistant/util/timeout.py b/homeassistant/util/timeout.py index 6c1de55748f..e2e969d46d2 100644 --- a/homeassistant/util/timeout.py +++ b/homeassistant/util/timeout.py @@ -49,7 +49,7 @@ class _GlobalFreezeContext: self._loop.call_soon_threadsafe(self._enter) return self - def __exit__( # pylint: disable=useless-return + def __exit__( self, exc_type: type[BaseException], exc_val: BaseException, @@ -117,7 +117,7 @@ class _ZoneFreezeContext: self._loop.call_soon_threadsafe(self._enter) return self - def __exit__( # pylint: disable=useless-return + def __exit__( self, exc_type: type[BaseException], exc_val: BaseException, diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py index 7d70d23c00c..8aae8ff104e 100644 --- a/homeassistant/util/volume.py +++ b/homeassistant/util/volume.py @@ -1,7 +1,7 @@ """Volume conversion util functions.""" from __future__ import annotations -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index b5840a79e8d..2e31b212f1f 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -28,7 +28,7 @@ from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass # mypy: allow-untyped-calls, no-warn-return-any -JSON_TYPE = list | dict | str # pylint: disable=invalid-name +JSON_TYPE = list | dict | str _DictT = TypeVar("_DictT", bound=dict) _LOGGER = logging.getLogger(__name__) diff --git a/machine/green b/machine/green new file mode 100644 index 00000000000..c1d74d3528e --- /dev/null +++ b/machine/green @@ -0,0 +1,4 @@ +ARG \ + BUILD_FROM + +FROM $BUILD_FROM diff --git a/mypy.ini b/mypy.ini index 7d1ec19c4d5..82cce328c6a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -17,7 +17,7 @@ warn_unused_configs = true warn_unused_ignores = true enable_error_code = ignore-without-code, redundant-self, truthy-iterable disable_error_code = annotation-unchecked -strict_concatenate = false +extra_checks = false check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true @@ -291,6 +291,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.alexa.*] +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 @@ -792,6 +802,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.doorbird.*] +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.dormakaba_dkey.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1232,6 +1252,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homeassistant_green.*] +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_hardware.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1572,6 +1602,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ipp.*] +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.iqvia.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1692,6 +1732,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lawn_mower.*] +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.lcn.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1852,6 +1902,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.media_extractor.*] +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.media_player.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2722,6 +2782,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.starlink.*] +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.statistics.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2973,6 +3043,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trafikverket_camera.*] +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.trafikverket_ferry.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 06b9c965cbe..e4403bd7c30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.8.4" +version = "2023.9.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -25,8 +25,8 @@ requires-python = ">=3.11.0" dependencies = [ "aiohttp==3.8.5", "astral==2.2", - "async-timeout==4.0.2", - "attrs==22.2.0", + "async-timeout==4.0.3", + "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==22.9.0", "bcrypt==4.0.1", @@ -35,7 +35,7 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.24.1", - "home-assistant-bluetooth==1.10.2", + "home-assistant-bluetooth==1.10.3", "ifaddr==0.2.0", "Jinja2==3.1.2", "lru-dict==1.2.0", @@ -45,12 +45,13 @@ dependencies = [ # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", "orjson==3.9.2", + "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", "typing-extensions>=4.7.0,<5.0", - "ulid-transform==0.8.0", + "ulid-transform==0.8.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.9.2", @@ -119,18 +120,6 @@ fail-on = [ [tool.pylint.BASIC] class-const-naming-style = "any" -good-names = [ - "_", - "ev", - "ex", - "fp", - "i", - "id", - "j", - "k", - "Run", - "ip", -] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: @@ -265,6 +254,7 @@ disable = [ "missing-module-docstring", # D100 "multiple-imports", #E401 "singleton-comparison", # E711, E712 + "subprocess-run-check", # PLW1510 "superfluous-parens", # UP034 "ungrouped-imports", # I001 "unidiomatic-typecheck", # E721 @@ -427,11 +417,95 @@ norecursedirs = [ log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" -filterwarnings = ["error::sqlalchemy.exc.SAWarning"] +filterwarnings = [ + "error::sqlalchemy.exc.SAWarning", + + # -- HomeAssistant - aiohttp + # Overwrite web.Application to pass a custom default argument to _make_request + "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning", + # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally + "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client", + # Modify app state for testing + "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban", + + # -- Tests + # Ignore custom pytest marks + "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", + + # -- design choice 3rd party + # https://github.com/gwww/elkm1/blob/2.2.5/elkm1_lib/util.py#L8-L19 + "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", + # https://github.com/michaeldavie/env_canada/blob/v0.5.36/env_canada/ec_cache.py + "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", + # https://github.com/bachya/regenmaschine/blob/2023.08.0/regenmaschine/client.py#L51 + "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", + + # -- Setuptools DeprecationWarnings + # https://github.com/googleapis/google-cloud-python/issues/11184 + # https://github.com/zopefoundation/meta/issues/194 + "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", + "ignore:Deprecated call to `pkg_resources.declare_namespace\\('google.*'\\)`:DeprecationWarning:google.rpc", + + # -- tracked upstream / open PRs + # https://github.com/caronc/apprise/issues/659 - v1.4.5 + "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", + # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 + "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", + # https://github.com/eclipse/paho.mqtt.python/issues/653 - v1.6.1 + # https://github.com/eclipse/paho.mqtt.python/pull/665 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", + # https://github.com/PythonCharmers/python-future/issues/488 - v0.18.3 + "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", + # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 + "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", + # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - v0.5.3 + "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", + # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 + # Should resolve itself once pytest-xdist 4.0 is released and the option is removed + "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", + + # -- fixed, waiting for release / update + # https://github.com/gurumitts/pylutron-caseta/pull/143 - >0.18.1 + "ignore:ssl.PROTOCOL_TLSv1_2 is deprecated:DeprecationWarning:pylutron_caseta.smartbridge", + # https://github.com/Danielhiversen/pyMillLocal/pull/8 - >=0.3.0 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:mill_local", + + # -- not helpful + # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 + "ignore:The module pyatmo.* is deprecated:DeprecationWarning:pyatmo", + + # -- unmaintained projects, last release about 2+ years + # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", + # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", + # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", + # https://pypi.org/project/emulated-roku/ - v0.2.1 - 2020-01-23 (archived) + "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", + # https://pypi.org/project/foobot_async/ - v1.0.0 - 2020-11-24 + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` + # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 + # https://github.com/vaidik/commentjson/issues/51 + # Fixed upstream, commentjson depends on old version and seems to be unmaintained + "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", + # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", + # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 + "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", + # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 + "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", + "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", + # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 + "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", + # https://pypi.org/project/vilfo-api-client/ - v0.4.1 - 2021-11-06 + "ignore:Function 'semver.compare' is deprecated. Deprecated since version 3.0.0:PendingDeprecationWarning:.*vilfo.client", + # https://pypi.org/project/webrtcvad/ - v2.0.10 - 2017-01-08 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:webrtcvad", +] [tool.ruff] -target-version = "py310" - select = [ "B002", # Python does not support the unary prefix increment "B007", # Loop control variable {name} not used within loop body @@ -441,6 +515,8 @@ select = [ "C", # complexity "COM818", # Trailing comma on bare tuple prohibited "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake "G", # flake8-logging-format @@ -487,6 +563,7 @@ select = [ "SIM401", # Use get from dict with default instead of an if block "T100", # Trace found: {name} used "T20", # flake8-print + "TID251", # Banned imports "TRY004", # Prefer TypeError exception for invalid type "TRY200", # Use raise from to specify exception cause "TRY302", # Remove exception handler; error is immediately re-raised @@ -530,12 +607,16 @@ voluptuous = "vol" [tool.ruff.flake8-pytest-style] fixture-parentheses = false +[tool.ruff.flake8-tidy-imports.banned-api] +"pytz".msg = "use zoneinfo instead" + [tool.ruff.isort] force-sort-within-sections = true known-first-party = [ "homeassistant", ] combine-as-imports = true +split-on-trailing-comma = false [tool.ruff.per-file-ignores] diff --git a/requirements.txt b/requirements.txt index 4106a8515d1..e7a3b0fc4c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,15 +3,15 @@ # Home Assistant Core aiohttp==3.8.5 astral==2.2 -async-timeout==4.0.2 -attrs==22.2.0 +async-timeout==4.0.3 +attrs==23.1.0 atomicwrites-homeassistant==1.4.1 awesomeversion==22.9.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.24.1 -home-assistant-bluetooth==1.10.2 +home-assistant-bluetooth==1.10.3 ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 @@ -19,12 +19,13 @@ PyJWT==2.8.0 cryptography==41.0.3 pyOpenSSL==23.2.0 orjson==3.9.2 +packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 typing-extensions>=4.7.0,<5.0 -ulid-transform==0.8.0 +ulid-transform==0.8.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.2 diff --git a/requirements_all.txt b/requirements_all.txt index 34d6b48c0db..d656b0dbb48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,13 +2,13 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.2.2 +AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 # homeassistant.components.honeywell -AIOSomecomfort==0.0.15 +AIOSomecomfort==0.0.17 # homeassistant.components.adax Adax-local==0.1.5 @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.6.5 +HATasmota==0.7.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -73,7 +73,7 @@ PyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -PyMetno==0.10.0 +PyMetno==0.11.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.9 @@ -96,7 +96,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.38.0 +PySwitchbot==0.39.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -139,9 +139,6 @@ TwitterAPI==2.7.12 # homeassistant.components.onvif WSDiscovery==2.0.0 -# homeassistant.components.waze_travel_time -WazeRouteCalculator==0.14 - # homeassistant.components.accuweather accuweather==1.0.0 @@ -191,7 +188,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.5 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -206,10 +203,13 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.2 +aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.1.0 +aiobotocore==2.6.0 + +# homeassistant.components.comelit +aiocomelit==0.0.5 # homeassistant.components.dhcp aiodiscover==1.4.16 @@ -231,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==15.1.15 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 @@ -249,7 +249,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.16 +aiohomekit==3.0.2 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -283,7 +283,7 @@ aiolivisi==0.0.19 aiolookin==1.0.0 # homeassistant.components.lyric -aiolyric==1.0.9 +aiolyric==1.1.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -304,7 +304,7 @@ aiooncue==0.3.5 aioopenexchangerates==0.4.0 # homeassistant.components.pegel_online -aiopegelonline==0.0.5 +aiopegelonline==0.0.6 # homeassistant.components.acmeda aiopulse==0.4.3 @@ -316,7 +316,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.1.0 +aiopvpc==4.2.2 # homeassistant.components.lidarr # homeassistant.components.radarr @@ -324,7 +324,7 @@ aiopvpc==4.1.0 aiopyarr==23.4.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.2 +aioqsw==0.3.4 # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -332,6 +332,9 @@ aiorecollect==1.0.8 # homeassistant.components.ridwell aioridwell==2023.07.0 +# homeassistant.components.ruckus_unleashed +aioruckus==0.31 + # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -339,7 +342,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.4.0 +aioshelly==6.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -357,14 +360,20 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==52 +aiounifi==61 # homeassistant.components.vlc_telnet aiovlc==0.1.0 +# homeassistant.components.vodafone_station +aiovodafone==0.0.6 + +# homeassistant.components.waqi +aiowaqi==0.2.1 + # homeassistant.components.watttime aiowatttime==0.1.1 @@ -378,7 +387,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.5.3 +airthings-ble==0.5.6-2 # homeassistant.components.airthings airthings-cloud==0.1.0 @@ -443,7 +452,7 @@ asterisk-mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.34.1 +async-upnp-client==0.35.0 # homeassistant.components.esphome async_interrupt==1.1.1 @@ -455,7 +464,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.3.5 +asyncsleepiq==1.3.7 # homeassistant.components.aten_pe # atenpdu==0.3.2 @@ -494,25 +503,25 @@ batinfo==0.4.2 # beacontools[scan]==2.1.0 # homeassistant.components.scrape -beautifulsoup4==4.11.1 +beautifulsoup4==4.12.2 # homeassistant.components.beewi_smartclim # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.35.9 +bellows==0.36.2 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.13.9 +bimmer-connected==0.14.0 # homeassistant.components.bizkaibus bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.0 # homeassistant.components.blebox blebox-uniapi==2.1.4 @@ -540,7 +549,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.6.1 +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 @@ -550,7 +559,7 @@ boschshcpy==0.2.57 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.20.24 +boto3==1.28.17 # homeassistant.components.broadlink broadlink==0.18.3 @@ -568,7 +577,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.0.0 +bthome-ble==3.1.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -580,7 +589,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.2.0 +caldav==1.3.6 # homeassistant.components.circuit circuit-webhook==1.0.1 @@ -632,7 +641,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.90.1 +dbus-fast==1.94.1 # homeassistant.components.debugpy debugpy==1.6.7 @@ -661,7 +670,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.3.2 +devolo-plc-api==1.4.0 # homeassistant.components.directv directv==0.4.0 @@ -727,7 +736,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.4.1 +energyzero==0.5.0 # homeassistant.components.enocean enocean==0.50 @@ -738,9 +747,6 @@ enturclient==0.2.4 # homeassistant.components.environment_canada env-canada==0.5.36 -# homeassistant.components.enphase_envoy -envoy-reader==0.20.1 - # homeassistant.components.season ephem==4.1.2 @@ -804,7 +810,7 @@ flux-led==1.0.2 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.4.0 +fnv-hash-fast==0.4.1 # homeassistant.components.foobot foobot-async==1.0.0 @@ -829,7 +835,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.0.2 +gardena_bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -952,7 +958,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.69.0 +hass-nabucasa==0.70.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -988,7 +994,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230802.1 +home-assistant-frontend==20230906.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 @@ -1027,7 +1033,7 @@ ibeacon-ble==1.0.1 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.5.4 +ical==5.0.1 # homeassistant.components.ping icmplib==3.0 @@ -1207,7 +1213,7 @@ micloud==0.5 mill-local==0.2.0 # homeassistant.components.mill -millheater==0.10.0 +millheater==0.11.1 # homeassistant.components.minio minio==7.1.12 @@ -1317,7 +1323,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.1.0 +odp-amsterdam==5.3.1 # homeassistant.components.oem oemthermostat==1.1.1 @@ -1368,7 +1374,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.31 +opower==0.0.33 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1432,7 +1438,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.1 +plugwise==0.31.9 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1453,7 +1459,7 @@ prayer-times-calculator==0.0.6 proliphix==0.4.1 # homeassistant.components.prometheus -prometheus-client==0.7.1 +prometheus-client==0.17.1 # homeassistant.components.proxmoxve proxmoxer==2.0.1 @@ -1541,7 +1547,7 @@ pyRFXtrx==0.30.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1587,7 +1593,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.neato -pybotvac==0.0.23 +pybotvac==0.0.24 # homeassistant.components.braviatv pybravia==0.3.3 @@ -1623,7 +1629,7 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.10.5 +pydaikin==2.11.1 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -1638,19 +1644,19 @@ pydelijn==1.1.0 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.1 +pydiscovergy==2.0.3 # homeassistant.components.doods pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2023.7.1 +pydrawise==2023.8.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 # homeassistant.components.duotecno -pyduotecno==2023.8.3 +pyduotecno==2023.8.4 # homeassistant.components.ebox pyebox==1.1.4 @@ -1664,6 +1670,9 @@ pyedimax==0.2.1 # homeassistant.components.efergy pyefergy==22.1.1 +# homeassistant.components.enphase_envoy +pyenphase==1.9.1 + # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1701,7 +1710,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.8 +pyfritzhome==0.6.9 # homeassistant.components.ifttt pyfttt==0.3 @@ -1746,7 +1755,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.3 +pyipp==0.14.4 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1842,7 +1851,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.3.1 +pymodbus==3.4.1 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1907,7 +1916,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.10.1 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1969,9 +1978,6 @@ pyrituals==0.0.6 # homeassistant.components.thread pyroute2==0.7.5 -# homeassistant.components.ruckus_unleashed -pyruckus==0.16 - # homeassistant.components.rympro pyrympro==0.0.7 @@ -1981,6 +1987,9 @@ pysabnzbd==1.1.1 # homeassistant.components.saj pysaj==0.0.16 +# homeassistant.components.schlage +pyschlage==2023.8.1 + # homeassistant.components.sensibo pysensibo==1.0.33 @@ -2026,13 +2035,13 @@ pysml==0.0.12 pysnmplib==5.0.21 # homeassistant.components.snooz -pysnooz==0.8.3 +pysnooz==0.8.6 # homeassistant.components.soma pysoma==0.0.12 # homeassistant.components.spc -pyspcwebgw==0.4.0 +pyspcwebgw==0.7.0 # homeassistant.components.squeezebox pysqueezebox==0.6.3 @@ -2065,7 +2074,7 @@ python-awair==0.2.4 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==0.5.11 +python-bsblan==0.5.16 # homeassistant.components.clementine python-clementine-remote==1.0.1 @@ -2134,11 +2143,11 @@ python-mystrom==2.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.0.10 +python-opensky==0.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.3.0 +python-otbr-api==2.5.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -2185,10 +2194,11 @@ pytraccar==1.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 +# homeassistant.components.trafikverket_camera # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.3 +pytrafikverket==0.3.5 # homeassistant.components.usb pyudev==0.23.2 @@ -2220,11 +2230,14 @@ pyvlx==0.2.20 # homeassistant.components.volumio pyvolumio==0.1.5 +# homeassistant.components.waze_travel_time +pywaze==0.3.0 + # homeassistant.components.html5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.2.1 +pywemo==1.3.0 # homeassistant.components.wilight pywilight==0.0.74 @@ -2238,6 +2251,9 @@ pyws66i==1.1 # homeassistant.components.xeoma pyxeoma==1.4.1 +# homeassistant.components.yardian +pyyardian==1.1.0 + # homeassistant.components.qrcode pyzbar==0.1.7 @@ -2272,13 +2288,13 @@ raspyrfm-client==1.2.8 regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.1.13 +renault-api==0.2.0 # homeassistant.components.renson renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2389,7 +2405,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.05.0 +simplisafe-python==2023.08.0 # homeassistant.components.sisyphus sisyphus-control==3.1.3 @@ -2449,7 +2465,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.1.1 +starlink-grpc-core==1.1.2 # homeassistant.components.statsd statsd==3.2.1 @@ -2548,7 +2564,7 @@ tilt-ble==0.2.3 tmb==0.0.4 # homeassistant.components.todoist -todoist-api-python==2.0.2 +todoist-api-python==2.1.2 # homeassistant.components.tolo tololib==0.1.0b4 @@ -2595,6 +2611,9 @@ unifi-discovery==1.1.7 # homeassistant.components.unifiled unifiled==0.11 +# homeassistant.components.zha +universal-silabs-flasher==0.0.13 + # homeassistant.components.upb upb-lib==0.5.4 @@ -2634,7 +2653,7 @@ volkszaehler==0.4.0 volvooncall==0.10.3 # homeassistant.components.verisure -vsure==2.6.4 +vsure==2.6.6 # homeassistant.components.vasttrafik vtjp==0.1.14 @@ -2652,9 +2671,6 @@ wakeonlan==2.1.0 # homeassistant.components.wallbox wallbox==0.4.12 -# homeassistant.components.waqi -waqiasync==1.1.0 - # homeassistant.components.folder_watcher watchdog==2.3.1 @@ -2695,7 +2711,7 @@ wyoming==1.1.0 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.20.0 +xiaomi-ble==0.21.1 # homeassistant.components.knx xknx==2.11.2 @@ -2706,6 +2722,7 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest +# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate @@ -2722,7 +2739,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.2.3 # homeassistant.components.august -yalexs==1.5.1 +yalexs==1.8.0 # homeassistant.components.yeelight yeelight==0.7.13 @@ -2743,19 +2760,19 @@ youtubeaio==1.1.5 yt-dlp==2023.7.6 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zengge zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.74.0 +zeroconf==0.91.1 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.102 +zha-quirks==0.0.103 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2776,13 +2793,13 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.56.4 +zigpy==0.57.1 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.49.0 +zwave-js-server-python==0.51.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index bf71ed4d255..a2533d0ef2b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,12 +8,12 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==2.15.4 -coverage==7.2.7 +coverage==7.3.0 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.4.1 +mypy==1.5.1 pre-commit==3.3.3 -pydantic==1.10.11 +pydantic==1.10.12 pylint==2.17.4 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 @@ -31,19 +31,24 @@ pytest-xdist==3.3.1 pytest==7.3.1 requests_mock==1.11.0 respx==0.20.2 -syrupy==4.0.8 -tqdm==4.65.0 +syrupy==4.2.1 +tqdm==4.66.1 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 +types-beautifulsoup4==4.12.0.6 +types-caldav==1.3.0.0 types-chardet==0.1.5 types-decorator==5.1.8.3 types-enum34==1.1.8 types-ipaddress==1.0.8 types-paho-mqtt==1.6.0.6 +types-Pillow==10.0.0.2 types-pkg-resources==0.1.3 +types-psutil==5.9.5 types-python-dateutil==2.8.19.13 types-python-slugify==0.1.2 types-pytz==2023.3.0.0 types-PyYAML==6.0.12.2 types-requests==2.31.0.1 +types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1000c01a2d2..308759108ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,13 +4,13 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.2.2 +AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 # homeassistant.components.honeywell -AIOSomecomfort==0.0.15 +AIOSomecomfort==0.0.17 # homeassistant.components.adax Adax-local==0.1.5 @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.6.5 +HATasmota==0.7.0 # homeassistant.components.doods # homeassistant.components.generic @@ -63,7 +63,7 @@ PyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -PyMetno==0.10.0 +PyMetno==0.11.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.9 @@ -86,7 +86,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.38.0 +PySwitchbot==0.39.1 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -120,9 +120,6 @@ SQLAlchemy==2.0.15 # homeassistant.components.onvif WSDiscovery==2.0.0 -# homeassistant.components.waze_travel_time -WazeRouteCalculator==0.14 - # homeassistant.components.accuweather accuweather==1.0.0 @@ -172,7 +169,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.5 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -187,10 +184,13 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.2 +aiobafi6==0.9.0 # homeassistant.components.aws -aiobotocore==2.1.0 +aiobotocore==2.6.0 + +# homeassistant.components.comelit +aiocomelit==0.0.5 # homeassistant.components.dhcp aiodiscover==1.4.16 @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==15.1.15 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 @@ -227,7 +227,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.16 +aiohomekit==3.0.2 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -258,7 +258,7 @@ aiolivisi==0.0.19 aiolookin==1.0.0 # homeassistant.components.lyric -aiolyric==1.0.9 +aiolyric==1.1.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 @@ -279,7 +279,7 @@ aiooncue==0.3.5 aioopenexchangerates==0.4.0 # homeassistant.components.pegel_online -aiopegelonline==0.0.5 +aiopegelonline==0.0.6 # homeassistant.components.acmeda aiopulse==0.4.3 @@ -291,7 +291,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.1.0 +aiopvpc==4.2.2 # homeassistant.components.lidarr # homeassistant.components.radarr @@ -299,7 +299,7 @@ aiopvpc==4.1.0 aiopyarr==23.4.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.2 +aioqsw==0.3.4 # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -307,6 +307,9 @@ aiorecollect==1.0.8 # homeassistant.components.ridwell aioridwell==2023.07.0 +# homeassistant.components.ruckus_unleashed +aioruckus==0.31 + # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -314,7 +317,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.4.0 +aioshelly==6.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -332,14 +335,17 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==52 +aiounifi==61 # homeassistant.components.vlc_telnet aiovlc==0.1.0 +# homeassistant.components.vodafone_station +aiovodafone==0.0.6 + # homeassistant.components.watttime aiowatttime==0.1.1 @@ -353,7 +359,7 @@ aioymaps==1.2.2 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.5.3 +airthings-ble==0.5.6-2 # homeassistant.components.airthings airthings-cloud==0.1.0 @@ -397,13 +403,13 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.34.1 +async-upnp-client==0.35.0 # homeassistant.components.esphome async_interrupt==1.1.1 # homeassistant.components.sleepiq -asyncsleepiq==1.3.5 +asyncsleepiq==1.3.7 # homeassistant.components.aurora auroranoaa==0.0.3 @@ -421,19 +427,19 @@ azure-eventhub==5.11.1 base36==0.1.1 # homeassistant.components.scrape -beautifulsoup4==4.11.1 +beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.35.9 +bellows==0.36.2 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.13.9 +bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.0 # homeassistant.components.blebox blebox-uniapi==2.1.4 @@ -454,7 +460,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.6.1 +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 @@ -475,13 +481,13 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.0.0 +bthome-ble==3.1.0 # homeassistant.components.buienradar buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.2.0 +caldav==1.3.6 # homeassistant.components.coinbase coinbase==2.1.0 @@ -515,7 +521,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.90.1 +dbus-fast==1.94.1 # homeassistant.components.debugpy debugpy==1.6.7 @@ -538,7 +544,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.3.2 +devolo-plc-api==1.4.0 # homeassistant.components.directv directv==0.4.0 @@ -586,7 +592,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.4.1 +energyzero==0.5.0 # homeassistant.components.enocean enocean==0.50 @@ -594,9 +600,6 @@ enocean==0.50 # homeassistant.components.environment_canada env-canada==0.5.36 -# homeassistant.components.enphase_envoy -envoy-reader==0.20.1 - # homeassistant.components.season ephem==4.1.2 @@ -632,7 +635,7 @@ flux-led==1.0.2 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.4.0 +fnv-hash-fast==0.4.1 # homeassistant.components.foobot foobot-async==1.0.0 @@ -651,7 +654,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.0.2 +gardena_bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -750,7 +753,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.69.0 +hass-nabucasa==0.70.0 # homeassistant.components.conversation hassil==1.2.5 @@ -774,7 +777,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230802.1 +home-assistant-frontend==20230906.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 @@ -804,7 +807,7 @@ iaqualink==0.5.0 ibeacon-ble==1.0.1 # homeassistant.components.local_calendar -ical==4.5.4 +ical==5.0.1 # homeassistant.components.ping icmplib==3.0 @@ -924,7 +927,7 @@ micloud==0.5 mill-local==0.2.0 # homeassistant.components.mill -millheater==0.10.0 +millheater==0.11.1 # homeassistant.components.minio minio==7.1.12 @@ -1010,7 +1013,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.1.0 +odp-amsterdam==5.3.1 # homeassistant.components.omnilogic omnilogic==0.4.5 @@ -1037,7 +1040,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.31 +opower==0.0.33 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1083,7 +1086,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.1 +plugwise==0.31.9 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1098,7 +1101,7 @@ praw==7.5.0 prayer-times-calculator==0.0.6 # homeassistant.components.prometheus -prometheus-client==0.7.1 +prometheus-client==0.17.1 # homeassistant.components.hardware # homeassistant.components.recorder @@ -1156,7 +1159,7 @@ pyElectra==1.2.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1190,7 +1193,7 @@ pybalboa==1.0.1 pyblackbird==0.6 # homeassistant.components.neato -pybotvac==0.0.23 +pybotvac==0.0.24 # homeassistant.components.braviatv pybravia==0.3.3 @@ -1208,7 +1211,7 @@ pycoolmasternet-async==0.1.5 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.10.5 +pydaikin==2.11.1 # homeassistant.components.deconz pydeconz==113 @@ -1217,13 +1220,13 @@ pydeconz==113 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.1 +pydiscovergy==2.0.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 # homeassistant.components.duotecno -pyduotecno==2023.8.3 +pyduotecno==2023.8.4 # homeassistant.components.econet pyeconet==0.1.20 @@ -1231,6 +1234,9 @@ pyeconet==0.1.20 # homeassistant.components.efergy pyefergy==22.1.1 +# homeassistant.components.enphase_envoy +pyenphase==1.9.1 + # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1259,7 +1265,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.8 +pyfritzhome==0.6.9 # homeassistant.components.ifttt pyfttt==0.3 @@ -1292,7 +1298,7 @@ pyinsteon==1.4.3 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.3 +pyipp==0.14.4 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1364,7 +1370,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.3.1 +pymodbus==3.4.1 # homeassistant.components.monoprice pymonoprice==0.4 @@ -1417,7 +1423,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.10.1 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1464,15 +1470,15 @@ pyrituals==0.0.6 # homeassistant.components.thread pyroute2==0.7.5 -# homeassistant.components.ruckus_unleashed -pyruckus==0.16 - # homeassistant.components.rympro pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 +# homeassistant.components.schlage +pyschlage==2023.8.1 + # homeassistant.components.sensibo pysensibo==1.0.33 @@ -1512,13 +1518,13 @@ pysml==0.0.12 pysnmplib==5.0.21 # homeassistant.components.snooz -pysnooz==0.8.3 +pysnooz==0.8.6 # homeassistant.components.soma pysoma==0.0.12 # homeassistant.components.spc -pyspcwebgw==0.4.0 +pyspcwebgw==0.7.0 # homeassistant.components.squeezebox pysqueezebox==0.6.3 @@ -1536,7 +1542,7 @@ pytautulli==23.1.1 python-awair==0.2.4 # homeassistant.components.bsblan -python-bsblan==0.5.11 +python-bsblan==0.5.16 # homeassistant.components.ecobee python-ecobee-api==0.2.14 @@ -1566,11 +1572,11 @@ python-miio==0.5.12 python-mystrom==2.2.0 # homeassistant.components.opensky -python-opensky==0.0.10 +python-opensky==0.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.3.0 +python-otbr-api==2.5.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -1605,10 +1611,11 @@ pytraccar==1.0.0 # homeassistant.components.tradfri pytradfri[async]==9.0.1 +# homeassistant.components.trafikverket_camera # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.3 +pytrafikverket==0.3.5 # homeassistant.components.usb pyudev==0.23.2 @@ -1631,11 +1638,14 @@ pyvizio==0.1.61 # homeassistant.components.volumio pyvolumio==0.1.5 +# homeassistant.components.waze_travel_time +pywaze==0.3.0 + # homeassistant.components.html5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.2.1 +pywemo==1.3.0 # homeassistant.components.wilight pywilight==0.0.74 @@ -1668,13 +1678,13 @@ rapt-ble==0.1.2 regenmaschine==2023.06.0 # homeassistant.components.renault -renault-api==0.1.13 +renault-api==0.2.0 # homeassistant.components.renson renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.9 # homeassistant.components.rflink rflink==0.0.65 @@ -1749,7 +1759,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.05.0 +simplisafe-python==2023.08.0 # homeassistant.components.slack slackclient==2.5.0 @@ -1797,7 +1807,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.1.1 +starlink-grpc-core==1.1.2 # homeassistant.components.statsd statsd==3.2.1 @@ -1857,7 +1867,7 @@ thermopro-ble==0.4.5 tilt-ble==0.2.3 # homeassistant.components.todoist -todoist-api-python==2.0.2 +todoist-api-python==2.1.2 # homeassistant.components.tolo tololib==0.1.0b4 @@ -1898,6 +1908,9 @@ ultraheat-api==0.5.1 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 +# homeassistant.components.zha +universal-silabs-flasher==0.0.13 + # homeassistant.components.upb upb-lib==0.5.4 @@ -1934,7 +1947,7 @@ voip-utils==0.1.0 volvooncall==0.10.3 # homeassistant.components.verisure -vsure==2.6.4 +vsure==2.6.6 # homeassistant.components.vulcan vulcan-api==2.3.0 @@ -1980,7 +1993,7 @@ wyoming==1.1.0 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.20.0 +xiaomi-ble==0.21.1 # homeassistant.components.knx xknx==2.11.2 @@ -1991,6 +2004,7 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest +# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate @@ -2004,7 +2018,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.2.3 # homeassistant.components.august -yalexs==1.5.1 +yalexs==1.8.0 # homeassistant.components.yeelight yeelight==0.7.13 @@ -2019,16 +2033,16 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.74.0 +zeroconf==0.91.1 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.102 +zha-quirks==0.0.103 # homeassistant.components.zha zigpy-deconz==0.21.0 @@ -2043,10 +2057,10 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.56.4 +zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.49.0 +zwave-js-server-python==0.51.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index e91cbe1ff62..844d796e7af 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,5 +2,5 @@ black==23.7.0 codespell==2.2.2 -ruff==0.0.280 +ruff==0.0.285 yamllint==1.32.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b2954dc777b..101a57e419d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -9,9 +9,8 @@ from pathlib import Path import pkgutil import re import sys -from typing import Any - import tomllib +from typing import Any from homeassistant.util.yaml.loader import load_yaml from script.hassfest.model import Integration @@ -149,7 +148,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.23.3 +protobuf==4.24.0 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index f95a7b3b542..458391b1fb4 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -25,8 +25,6 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza -/homeassistant/const.py @epenet -/homeassistant/util/ @epenet # Integrations """.strip() diff --git a/script/hassfest/config_schema.py b/script/hassfest/config_schema.py index b794834161d..da2de9a6013 100644 --- a/script/hassfest/config_schema.py +++ b/script/hassfest/config_schema.py @@ -20,7 +20,7 @@ def _has_assignment(module: ast.Module, name: str) -> bool: continue if type(item) == ast.Assign: for target in item.targets: - if target.id == name: + if getattr(target, "id", None) == name: return True continue if item.target.id == name: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index c0733841ed5..31fd31dfc96 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -149,6 +149,7 @@ IGNORE_VIOLATIONS = { ("http", "network"), # This would be a circular dep ("zha", "homeassistant_hardware"), + ("zha", "homeassistant_sky_connect"), ("zha", "homeassistant_yellow"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 4515f52d8a3..9323b8e86c0 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -71,6 +71,7 @@ NO_IOT_CLASS = [ "history", "homeassistant", "homeassistant_alerts", + "homeassistant_green", "homeassistant_hardware", "homeassistant_sky_connect", "homeassistant_yellow", @@ -254,12 +255,8 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema( } ) ], - vol.Required("documentation"): vol.All( - vol.Url(), documentation_url # pylint: disable=no-value-for-parameter - ), - vol.Optional( - "issue_tracker" - ): vol.Url(), # pylint: disable=no-value-for-parameter + vol.Required("documentation"): vol.All(vol.Url(), documentation_url), + vol.Optional("issue_tracker"): vol.Url(), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], @@ -397,4 +394,5 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: ["pre-commit", "run", "--hook-stage", "manual", "prettier", "--files"] + manifests_resorted, stdout=subprocess.DEVNULL, + check=True, ) diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index ad4a0f64fe4..779d76078d6 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -51,8 +51,9 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { ] ), "disable_error_code": ", ".join(["annotation-unchecked"]), - # Strict_concatenate breaks passthrough ParamSpec typing - "strict_concatenate": "false", + # Impractical in real code + # E.g. this breaks passthrough ParamSpec typing with Concatenate + "extra_checks": "false", } # This is basically the list of checks which is enabled for "strict=true". diff --git a/script/hassfest/services.py b/script/hassfest/services.py index b3f59ab66a3..4a826f7cad9 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -25,10 +25,8 @@ def exists(value: Any) -> Any: return value -FIELD_SCHEMA = vol.Schema( +CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( { - vol.Optional("description"): str, - vol.Optional("name"): str, vol.Optional("example"): exists, vol.Optional("default"): exists, vol.Optional("values"): exists, @@ -46,7 +44,26 @@ FIELD_SCHEMA = vol.Schema( } ) -SERVICE_SCHEMA = vol.Any( +CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( + { + vol.Optional("description"): str, + vol.Optional("name"): str, + } +) + +CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ), + vol.Optional("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + } + ), + None, +) + +CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( vol.Schema( { vol.Optional("description"): str, @@ -54,13 +71,23 @@ SERVICE_SCHEMA = vol.Any( vol.Optional("target"): vol.Any( selector.TargetSelector.CONFIG_SCHEMA, None ), - vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + vol.Optional("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), } ), None, ) -SERVICES_SCHEMA = vol.Schema({cv.slug: SERVICE_SCHEMA}) +CORE_INTEGRATION_SERVICES_SCHEMA = vol.Schema( + {cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA} +) +CUSTOM_INTEGRATION_SERVICES_SCHEMA = vol.Schema( + {cv.slug: CUSTOM_INTEGRATION_SERVICE_SCHEMA} +) + +VALIDATE_AS_CUSTOM_INTEGRATION = { + # Adding translations would be a breaking change + "foursquare", +} def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: @@ -99,7 +126,13 @@ def validate_services(config: Config, integration: Integration) -> None: return try: - services = SERVICES_SCHEMA(data) + if ( + integration.core + and integration.domain not in VALIDATE_AS_CUSTOM_INTEGRATION + ): + services = CORE_INTEGRATION_SERVICES_SCHEMA(data) + else: + services = CUSTOM_INTEGRATION_SERVICES_SCHEMA(data) except vol.Invalid as err: integration.add_error( "services", f"Invalid services.yaml: {humanize_error(data, err)}" @@ -118,6 +151,10 @@ def validate_services(config: Config, integration: Integration) -> None: with contextlib.suppress(ValueError): strings = json.loads(strings_file.read_text()) + error_msg_suffix = "in the translations file" + if not integration.core: + error_msg_suffix = f"and is not {error_msg_suffix}" + # For each service in the integration, check if the description if set, # if not, check if it's in the strings file. If not, add an error. for service_name, service_schema in services.items(): @@ -129,7 +166,7 @@ def validate_services(config: Config, integration: Integration) -> None: except KeyError: integration.add_error( "services", - f"Service {service_name} has no name and is not in the translations file", + f"Service {service_name} has no name {error_msg_suffix}", ) if "description" not in service_schema: @@ -138,12 +175,21 @@ def validate_services(config: Config, integration: Integration) -> None: except KeyError: integration.add_error( "services", - f"Service {service_name} has no description and is not in the translations file", + f"Service {service_name} has no description {error_msg_suffix}", ) # The same check is done for the description in each of the fields of the # service schema. for field_name, field_schema in service_schema.get("fields", {}).items(): + if "name" not in field_schema: + try: + strings["services"][service_name]["fields"][field_name]["name"] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has a field {field_name} with no name {error_msg_suffix}", + ) + if "description" not in field_schema: try: strings["services"][service_name]["fields"][field_name][ @@ -152,7 +198,7 @@ def validate_services(config: Config, integration: Integration) -> None: except KeyError: integration.add_error( "services", - f"Service {service_name} has a field {field_name} with no description and is not in the translations file", + f"Service {service_name} has a field {field_name} with no description {error_msg_suffix}", ) if "selector" in field_schema: diff --git a/script/lint_and_test.py b/script/lint_and_test.py index 5a3d448c1f4..27963758415 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -40,7 +40,7 @@ def printc(the_color, *args): def validate_requirements_ok(): """Validate requirements, returns True of ok.""" - # pylint: disable-next=import-error,import-outside-toplevel + # pylint: disable-next=import-outside-toplevel from gen_requirements_all import main as req_main return req_main(True) == 0 diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 2d4454c254b..42a8355db59 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -77,11 +77,13 @@ def main(): pipe_null = {} if args.develop else {"stdout": subprocess.DEVNULL} print("Running hassfest to pick up new information.") - subprocess.run(["python", "-m", "script.hassfest"], **pipe_null) + subprocess.run(["python", "-m", "script.hassfest"], **pipe_null, check=True) print() print("Running gen_requirements_all to pick up new information.") - subprocess.run(["python", "-m", "script.gen_requirements_all"], **pipe_null) + subprocess.run( + ["python", "-m", "script.gen_requirements_all"], **pipe_null, check=True + ) print() print("Running script/translations_develop to pick up new translation strings.") @@ -95,13 +97,16 @@ def main(): info.domain, ], **pipe_null, + check=True, ) print() if args.develop: print("Running tests") print(f"$ pytest -vvv tests/components/{info.domain}") - subprocess.run(["pytest", "-vvv", f"tests/components/{info.domain}"]) + subprocess.run( + ["pytest", "-vvv", f"tests/components/{info.domain}"], check=True + ) print() docs.print_relevant_docs(args.template, info) diff --git a/script/translations/download.py b/script/translations/download.py index 6d4ce91263a..bcab3b511c3 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -44,7 +44,8 @@ def run_download_docker(): "json", "--unzip-to", "/opt/dest", - ] + ], + check=False, ) print() diff --git a/script/translations/upload.py b/script/translations/upload.py index 02d964a94c9..1a1819af863 100755 --- a/script/translations/upload.py +++ b/script/translations/upload.py @@ -42,6 +42,7 @@ def run_upload_docker(): "--convert-placeholders=false", "--replace-modified", ], + check=False, ) print() diff --git a/script/translations/util.py b/script/translations/util.py index 0c8c8a2a30f..9f41253fa02 100644 --- a/script/translations/util.py +++ b/script/translations/util.py @@ -48,7 +48,9 @@ def get_current_branch(): """Get current branch.""" return ( subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=subprocess.PIPE + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stdout=subprocess.PIPE, + check=True, ) .stdout.decode() .strip() diff --git a/script/version_bump.py b/script/version_bump.py index 4a38adbd677..4c4f8a97f09 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -1,13 +1,13 @@ #!/usr/bin/env python3 """Helper script to bump the current version.""" import argparse -from datetime import datetime import re import subprocess from packaging.version import Version from homeassistant import const +from homeassistant.util import dt as dt_util def _bump_release(release, bump_type): @@ -86,10 +86,7 @@ def bump_version(version, bump_type): if not version.is_devrelease: raise ValueError("Can only be run on dev release") - to_change["dev"] = ( - "dev", - datetime.utcnow().date().isoformat().replace("-", ""), - ) + to_change["dev"] = ("dev", dt_util.utcnow().strftime("%Y%m%d")) else: assert False, f"Unsupported type: {bump_type}" @@ -161,7 +158,10 @@ def main(): ) arguments = parser.parse_args() - if arguments.commit and subprocess.run(["git", "diff", "--quiet"]).returncode == 1: + if ( + arguments.commit + and subprocess.run(["git", "diff", "--quiet"], check=False).returncode == 1 + ): print("Cannot use --commit because git is dirty.") return @@ -177,7 +177,7 @@ def main(): if not arguments.commit: return - subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"]) + subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"], check=True) def test_bump_version(): @@ -203,7 +203,7 @@ def test_bump_version(): assert bump_version(Version("0.56.0.dev0"), "minor") == Version("0.56.0") assert bump_version(Version("0.56.2.dev0"), "minor") == Version("0.57.0") - today = datetime.utcnow().date().isoformat().replace("-", "") + today = dt_util.utcnow().strftime("%Y%m%d") assert bump_version(Version("0.56.0.dev0"), "nightly") == Version( f"0.56.0.dev{today}" ) diff --git a/tests/common.py b/tests/common.py index 4fdccced370..48bb38383c7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -5,7 +5,7 @@ import asyncio from collections import OrderedDict from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import functools as ft from functools import lru_cache from io import StringIO @@ -59,6 +59,7 @@ from homeassistant.helpers import ( entity, entity_platform, entity_registry as er, + event, intent, issue_registry as ir, recorder as recorder_helper, @@ -67,7 +68,7 @@ from homeassistant.helpers import ( storage, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe @@ -179,7 +180,7 @@ def get_test_home_assistant(): async def async_test_home_assistant(event_loop, load_registries=True): """Return a Home Assistant object pointing at test config dir.""" - hass = HomeAssistant() + hass = HomeAssistant(get_test_config_dir()) store = auth_store.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}, {}) ensure_auth_manager_loaded(hass.auth) @@ -231,7 +232,6 @@ async def async_test_home_assistant(event_loop, load_registries=True): hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} hass.config.location_name = "test home" - hass.config.config_dir = get_test_config_dir() hass.config.latitude = 32.87336 hass.config.longitude = -117.22743 hass.config.elevation = 0 @@ -256,6 +256,7 @@ async def async_test_home_assistant(event_loop, load_registries=True): # Load the registries entity.async_setup(hass) + loader.async_setup(hass) if load_registries: with patch( "homeassistant.helpers.storage.Store.async_load", return_value=None @@ -384,7 +385,7 @@ def async_fire_time_changed_exact( approach, as this is only for testing. """ if datetime_ is None: - utc_datetime = datetime.now(timezone.utc) + utc_datetime = datetime.now(UTC) else: utc_datetime = dt_util.as_utc(datetime_) @@ -397,29 +398,30 @@ def async_fire_time_changed( ) -> None: """Fire a time changed event. - This function will add up to 0.5 seconds to the time to ensure that - it accounts for the accidental synchronization avoidance code in repeating - listeners. + If called within the first 500 ms of a second, time will be bumped to exactly + 500 ms to match the async_track_utc_time_change event listeners and + DataUpdateCoordinator which spreads all updates between 0.05..0.50. + Background in PR https://github.com/home-assistant/core/pull/82233 As asyncio is cooperative, we can't guarantee that the event loop will run an event at the exact time we want. If you need to fire time changed for an exact microsecond, use async_fire_time_changed_exact. """ if datetime_ is None: - utc_datetime = datetime.now(timezone.utc) + utc_datetime = datetime.now(UTC) else: utc_datetime = dt_util.as_utc(datetime_) - if utc_datetime.microsecond < 500000: - # Allow up to 500000 microseconds to be added to the time - # to handle update_coordinator's and - # async_track_time_interval's - # staggering to avoid thundering herd. - utc_datetime = utc_datetime.replace(microsecond=500000) + # Increase the mocked time by 0.5 s to account for up to 0.5 s delay + # added to events scheduled by update_coordinator and async_track_time_interval + utc_datetime += timedelta(microseconds=event.RANDOM_MICROSECOND_MAX) _async_fire_time_changed(hass, utc_datetime, fire_all) +_MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution + + @callback def _async_fire_time_changed( hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool @@ -432,7 +434,7 @@ def _async_fire_time_changed( continue mock_seconds_into_future = timestamp - time.time() - future_seconds = task.when() - hass.loop.time() + future_seconds = task.when() - (hass.loop.time() + _MONOTONIC_RESOLUTION) if fire_all or mock_seconds_into_future >= future_seconds: with patch( @@ -678,7 +680,6 @@ def ensure_auth_manager_loaded(auth_mgr): class MockModule: """Representation of a fake module.""" - # pylint: disable=invalid-name def __init__( self, domain=None, @@ -753,7 +754,6 @@ class MockPlatform: __name__ = "homeassistant.components.light.bla" __file__ = "homeassistant/components/blah/light" - # pylint: disable=invalid-name def __init__( self, setup_platform=None, @@ -962,16 +962,6 @@ def patch_yaml_files(files_dict, endswith=True): return patch.object(yaml_loader, "open", mock_open_f, create=True) -def mock_coro(return_value=None, exception=None): - """Return a coro that returns a value or raise an exception.""" - fut = asyncio.Future() - if exception is not None: - fut.set_exception(exception) - else: - fut.set_result(return_value) - return fut - - @contextmanager def assert_setup_component(count, domain=None): """Collect valid configuration from setup_component. @@ -1138,7 +1128,7 @@ class MockEntity(entity.Entity): return self._handle("device_class") @property - def device_info(self) -> entity.DeviceInfo | None: + def device_info(self) -> dr.DeviceInfo | None: """Info how it links to a device.""" return self._handle("device_info") @@ -1260,7 +1250,14 @@ def mock_storage( # To ensure that the data can be serialized _LOGGER.debug("Writing data to %s: %s", store.key, data_to_write) raise_contains_mocks(data_to_write) - data[store.key] = json.loads(json.dumps(data_to_write, cls=store._encoder)) + encoder = store._encoder + if encoder and encoder is not JSONEncoder: + # If they pass a custom encoder that is not the + # default JSONEncoder, we use the slow path of json.dumps + dump = ft.partial(json.dumps, cls=store._encoder) + else: + dump = _orjson_default_encoder + data[store.key] = json.loads(dump(data_to_write)) async def mock_remove(store: storage.Store) -> None: """Remove data.""" @@ -1329,8 +1326,11 @@ def mock_integration( integration._import_platform = mock_import_platform _LOGGER.info("Adding mock integration: %s", module.DOMAIN) - hass.data.setdefault(loader.DATA_INTEGRATIONS, {})[module.DOMAIN] = integration - hass.data.setdefault(loader.DATA_COMPONENTS, {})[module.DOMAIN] = module + integration_cache = hass.data[loader.DATA_INTEGRATIONS] + integration_cache[module.DOMAIN] = integration + + module_cache = hass.data[loader.DATA_COMPONENTS] + module_cache[module.DOMAIN] = module return integration @@ -1354,9 +1354,9 @@ def mock_platform( platform_path is in form hue.config_flow. """ - domain, platform_name = platform_path.split(".") - integration_cache = hass.data.setdefault(loader.DATA_INTEGRATIONS, {}) - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + domain = platform_path.split(".")[0] + integration_cache = hass.data[loader.DATA_INTEGRATIONS] + module_cache = hass.data[loader.DATA_COMPONENTS] if domain not in integration_cache: mock_integration(hass, MockModule(domain)) diff --git a/tests/components/accuweather/snapshots/test_diagnostics.ambr b/tests/components/accuweather/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b3c0c1de752 --- /dev/null +++ b/tests/components/accuweather/snapshots/test_diagnostics.ambr @@ -0,0 +1,304 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry_data': dict({ + 'api_key': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'Home', + }), + 'coordinator_data': dict({ + 'ApparentTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 73.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 22.8, + }), + }), + 'Ceiling': dict({ + 'Imperial': dict({ + 'Unit': 'ft', + 'UnitType': 0, + 'Value': 10500.0, + }), + 'Metric': dict({ + 'Unit': 'm', + 'UnitType': 5, + 'Value': 3200.0, + }), + }), + 'CloudCover': 10, + 'DewPoint': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 61.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 16.2, + }), + }), + 'HasPrecipitation': False, + 'IndoorRelativeHumidity': 67, + 'ObstructionsToVisibility': '', + 'Past24HourTemperatureDeparture': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 0.3, + }), + }), + 'Precip1hr': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 0.0, + }), + }), + 'PrecipitationSummary': dict({ + 'Past12Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.15, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 3.8, + }), + }), + 'Past18Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.2, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 5.1, + }), + }), + 'Past24Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.3, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 7.6, + }), + }), + 'Past3Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.05, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 1.3, + }), + }), + 'Past6Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.05, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 1.3, + }), + }), + 'Past9Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.1, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 2.5, + }), + }), + 'PastHour': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 0.0, + }), + }), + 'Precipitation': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 0.0, + }), + }), + }), + 'PrecipitationType': None, + 'Pressure': dict({ + 'Imperial': dict({ + 'Unit': 'inHg', + 'UnitType': 12, + 'Value': 29.88, + }), + 'Metric': dict({ + 'Unit': 'mb', + 'UnitType': 14, + 'Value': 1012.0, + }), + }), + 'PressureTendency': dict({ + 'Code': 'F', + 'LocalizedText': 'Falling', + }), + 'RealFeelTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 77.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 25.1, + }), + }), + 'RealFeelTemperatureShade': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 70.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 21.1, + }), + }), + 'RelativeHumidity': 67, + 'Temperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 73.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 22.6, + }), + }), + 'UVIndex': 6, + 'UVIndexText': 'High', + 'Visibility': dict({ + 'Imperial': dict({ + 'Unit': 'mi', + 'UnitType': 2, + 'Value': 10.0, + }), + 'Metric': dict({ + 'Unit': 'km', + 'UnitType': 6, + 'Value': 16.1, + }), + }), + 'WeatherIcon': 1, + 'WeatherText': 'Sunny', + 'WetBulbTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 65.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 18.6, + }), + }), + 'Wind': dict({ + 'Direction': dict({ + 'Degrees': 180, + 'English': 'S', + 'Localized': 'S', + }), + 'Speed': dict({ + 'Imperial': dict({ + 'Unit': 'mi/h', + 'UnitType': 9, + 'Value': 9.0, + }), + 'Metric': dict({ + 'Unit': 'km/h', + 'UnitType': 7, + 'Value': 14.5, + }), + }), + }), + 'WindChillTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 73.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 22.8, + }), + }), + 'WindGust': dict({ + 'Speed': dict({ + 'Imperial': dict({ + 'Unit': 'mi/h', + 'UnitType': 9, + 'Value': 12.6, + }), + 'Metric': dict({ + 'Unit': 'km/h', + 'UnitType': 7, + 'Value': 20.3, + }), + }), + }), + 'forecast': list([ + ]), + }), + }) +# --- diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr new file mode 100644 index 00000000000..521393af71b --- /dev/null +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]), + }) +# --- +# name: test_forecast_subscription + list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]) +# --- +# name: test_forecast_subscription.1 + list([ + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 58, + 'condition': 'lightning-rainy', + 'datetime': '2020-07-26T05:00:00+00:00', + 'precipitation': 2.5, + 'precipitation_probability': 60, + 'temperature': 29.5, + 'templow': 15.4, + 'uv_index': 5, + 'wind_bearing': 166, + 'wind_gust_speed': 29.6, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 52, + 'condition': 'partlycloudy', + 'datetime': '2020-07-27T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 26.2, + 'templow': 15.9, + 'uv_index': 7, + 'wind_bearing': 297, + 'wind_gust_speed': 14.8, + 'wind_speed': 9.3, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 65, + 'condition': 'partlycloudy', + 'datetime': '2020-07-28T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 31.7, + 'templow': 16.8, + 'uv_index': 7, + 'wind_bearing': 198, + 'wind_gust_speed': 24.1, + 'wind_speed': 16.7, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 45, + 'condition': 'partlycloudy', + 'datetime': '2020-07-29T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 9, + 'temperature': 24.0, + 'templow': 11.7, + 'uv_index': 6, + 'wind_bearing': 293, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.0, + }), + dict({ + 'apparent_temperature': 22.2, + 'cloud_coverage': 50, + 'condition': 'partlycloudy', + 'datetime': '2020-07-30T05:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 1, + 'temperature': 21.4, + 'templow': 12.2, + 'uv_index': 7, + 'wind_bearing': 280, + 'wind_gust_speed': 27.8, + 'wind_speed': 18.5, + }), + ]) +# --- diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index 98be70d9ec6..7c13f318cc3 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -1,4 +1,5 @@ """Test AccuWeather diagnostics.""" +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -10,7 +11,9 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass) @@ -23,10 +26,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result["config_entry_data"] == { - "api_key": "**REDACTED**", - "latitude": "**REDACTED**", - "longitude": "**REDACTED**", - "name": "Home", - } - assert result["coordinator_data"] == coordinator_data + assert result == snapshot diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 35f86bdb039..a7a94894be4 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -192,7 +192,7 @@ async def test_sensor_without_forecast( assert entry assert entry.unique_id == "0123456-windchilltemperature" - state = hass.states.get("sensor.home_wind_gust") + state = hass.states.get("sensor.home_wind_gust_speed") assert state assert state.state == "20.3" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -204,11 +204,11 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust") + entry = registry.async_get("sensor.home_wind_gust_speed") assert entry assert entry.unique_id == "0123456-windgust" - state = hass.states.get("sensor.home_wind") + state = hass.states.get("sensor.home_wind_speed") assert state assert state.state == "14.5" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -220,7 +220,7 @@ async def test_sensor_without_forecast( assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind") + entry = registry.async_get("sensor.home_wind_speed") assert entry assert entry.unique_id == "0123456-wind" @@ -232,7 +232,7 @@ async def test_sensor_with_forecast( await init_integration(hass, forecast=True) registry = er.async_get(hass) - state = hass.states.get("sensor.home_hours_of_sun_0d") + state = hass.states.get("sensor.home_hours_of_sun_today") assert state assert state.state == "7.2" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -240,11 +240,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_hours_of_sun_0d") + entry = registry.async_get("sensor.home_hours_of_sun_today") assert entry assert entry.unique_id == "0123456-hoursofsun-0" - state = hass.states.get("sensor.home_realfeel_temperature_max_0d") + state = hass.states.get("sensor.home_realfeel_temperature_max_today") assert state assert state.state == "29.8" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -252,10 +252,10 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_max_0d") + entry = registry.async_get("sensor.home_realfeel_temperature_max_today") assert entry - state = hass.states.get("sensor.home_realfeel_temperature_min_0d") + state = hass.states.get("sensor.home_realfeel_temperature_min_today") assert state assert state.state == "15.1" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -263,11 +263,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_min_0d") + entry = registry.async_get("sensor.home_realfeel_temperature_min_today") assert entry assert entry.unique_id == "0123456-realfeeltemperaturemin-0" - state = hass.states.get("sensor.home_thunderstorm_probability_day_0d") + state = hass.states.get("sensor.home_thunderstorm_probability_today") assert state assert state.state == "40" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -275,11 +275,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_thunderstorm_probability_day_0d") + entry = registry.async_get("sensor.home_thunderstorm_probability_today") assert entry assert entry.unique_id == "0123456-thunderstormprobabilityday-0" - state = hass.states.get("sensor.home_thunderstorm_probability_night_0d") + state = hass.states.get("sensor.home_thunderstorm_probability_tonight") assert state assert state.state == "40" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -287,11 +287,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_thunderstorm_probability_night_0d") + entry = registry.async_get("sensor.home_thunderstorm_probability_tonight") assert entry assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" - state = hass.states.get("sensor.home_uv_index_0d") + state = hass.states.get("sensor.home_uv_index_today") assert state assert state.state == "5" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -300,11 +300,11 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "moderate" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_uv_index_0d") + entry = registry.async_get("sensor.home_uv_index_today") assert entry assert entry.unique_id == "0123456-uvindex-0" - state = hass.states.get("sensor.home_air_quality_0d") + state = hass.states.get("sensor.home_air_quality_today") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -319,7 +319,7 @@ async def test_sensor_with_forecast( "unhealthy", ] - state = hass.states.get("sensor.home_cloud_cover_day_0d") + state = hass.states.get("sensor.home_cloud_cover_today") assert state assert state.state == "58" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -327,11 +327,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_cloud_cover_day_0d") + entry = registry.async_get("sensor.home_cloud_cover_today") assert entry assert entry.unique_id == "0123456-cloudcoverday-0" - state = hass.states.get("sensor.home_cloud_cover_night_0d") + state = hass.states.get("sensor.home_cloud_cover_tonight") assert state assert state.state == "65" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -339,10 +339,10 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_cloud_cover_night_0d") + entry = registry.async_get("sensor.home_cloud_cover_tonight") assert entry - state = hass.states.get("sensor.home_grass_pollen_0d") + state = hass.states.get("sensor.home_grass_pollen_today") assert state assert state.state == "0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -354,11 +354,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:grass" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_grass_pollen_0d") + entry = registry.async_get("sensor.home_grass_pollen_today") assert entry assert entry.unique_id == "0123456-grass-0" - state = hass.states.get("sensor.home_mold_pollen_0d") + state = hass.states.get("sensor.home_mold_pollen_today") assert state assert state.state == "0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -369,11 +369,11 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:blur" - entry = registry.async_get("sensor.home_mold_pollen_0d") + entry = registry.async_get("sensor.home_mold_pollen_today") assert entry assert entry.unique_id == "0123456-mold-0" - state = hass.states.get("sensor.home_ragweed_pollen_0d") + state = hass.states.get("sensor.home_ragweed_pollen_today") assert state assert state.state == "0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -384,11 +384,11 @@ async def test_sensor_with_forecast( assert state.attributes.get("level") == "low" assert state.attributes.get(ATTR_ICON) == "mdi:sprout" - entry = registry.async_get("sensor.home_ragweed_pollen_0d") + entry = registry.async_get("sensor.home_ragweed_pollen_today") assert entry assert entry.unique_id == "0123456-ragweed-0" - state = hass.states.get("sensor.home_realfeel_temperature_shade_max_0d") + state = hass.states.get("sensor.home_realfeel_temperature_shade_max_today") assert state assert state.state == "28.0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -396,22 +396,22 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_realfeel_temperature_shade_max_0d") + entry = registry.async_get("sensor.home_realfeel_temperature_shade_max_today") assert entry assert entry.unique_id == "0123456-realfeeltemperatureshademax-0" - state = hass.states.get("sensor.home_realfeel_temperature_shade_min_0d") + state = hass.states.get("sensor.home_realfeel_temperature_shade_min_today") assert state assert state.state == "15.1" 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 - entry = registry.async_get("sensor.home_realfeel_temperature_shade_min_0d") + entry = registry.async_get("sensor.home_realfeel_temperature_shade_min_today") assert entry assert entry.unique_id == "0123456-realfeeltemperatureshademin-0" - state = hass.states.get("sensor.home_tree_pollen_0d") + state = hass.states.get("sensor.home_tree_pollen_today") assert state assert state.state == "0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -423,11 +423,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) == "mdi:tree-outline" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.home_tree_pollen_0d") + entry = registry.async_get("sensor.home_tree_pollen_today") assert entry assert entry.unique_id == "0123456-tree-0" - state = hass.states.get("sensor.home_wind_day_0d") + state = hass.states.get("sensor.home_wind_speed_today") assert state assert state.state == "13.0" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -439,11 +439,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_day_0d") + entry = registry.async_get("sensor.home_wind_speed_today") assert entry assert entry.unique_id == "0123456-windday-0" - state = hass.states.get("sensor.home_wind_night_0d") + state = hass.states.get("sensor.home_wind_speed_tonight") assert state assert state.state == "7.4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -456,11 +456,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_night_0d") + entry = registry.async_get("sensor.home_wind_speed_tonight") assert entry assert entry.unique_id == "0123456-windnight-0" - state = hass.states.get("sensor.home_wind_gust_day_0d") + state = hass.states.get("sensor.home_wind_gust_speed_today") assert state assert state.state == "29.6" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -473,11 +473,11 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_day_0d") + entry = registry.async_get("sensor.home_wind_gust_speed_today") assert entry assert entry.unique_id == "0123456-windgustday-0" - state = hass.states.get("sensor.home_wind_gust_night_0d") + state = hass.states.get("sensor.home_wind_gust_speed_tonight") assert state assert state.state == "18.5" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -490,15 +490,15 @@ async def test_sensor_with_forecast( assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - entry = registry.async_get("sensor.home_wind_gust_night_0d") + entry = registry.async_get("sensor.home_wind_gust_speed_tonight") assert entry assert entry.unique_id == "0123456-windgustnight-0" - entry = registry.async_get("sensor.home_air_quality_0d") + entry = registry.async_get("sensor.home_air_quality_today") assert entry assert entry.unique_id == "0123456-airquality-0" - state = hass.states.get("sensor.home_solar_irradiance_day_0d") + state = hass.states.get("sensor.home_solar_irradiance_today") assert state assert state.state == "7447.1" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -508,11 +508,11 @@ async def test_sensor_with_forecast( == UnitOfIrradiance.WATTS_PER_SQUARE_METER ) - entry = registry.async_get("sensor.home_solar_irradiance_day_0d") + entry = registry.async_get("sensor.home_solar_irradiance_today") assert entry assert entry.unique_id == "0123456-solarirradianceday-0" - state = hass.states.get("sensor.home_solar_irradiance_night_0d") + state = hass.states.get("sensor.home_solar_irradiance_tonight") assert state assert state.state == "271.6" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -522,11 +522,11 @@ async def test_sensor_with_forecast( == UnitOfIrradiance.WATTS_PER_SQUARE_METER ) - entry = registry.async_get("sensor.home_solar_irradiance_night_0d") + entry = registry.async_get("sensor.home_solar_irradiance_tonight") assert entry assert entry.unique_id == "0123456-solarirradiancenight-0" - state = hass.states.get("sensor.home_condition_day_0d") + state = hass.states.get("sensor.home_condition_today") assert state assert ( state.state @@ -534,16 +534,16 @@ async def test_sensor_with_forecast( ) assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - entry = registry.async_get("sensor.home_condition_day_0d") + entry = registry.async_get("sensor.home_condition_today") assert entry assert entry.unique_id == "0123456-longphraseday-0" - state = hass.states.get("sensor.home_condition_night_0d") + state = hass.states.get("sensor.home_condition_tonight") assert state assert state.state == "Partly cloudy" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - entry = registry.async_get("sensor.home_condition_night_0d") + entry = registry.async_get("sensor.home_condition_tonight") assert entry assert entry.unique_id == "0123456-longphrasenight-0" @@ -629,7 +629,7 @@ async def test_sensor_imperial_units(hass: HomeAssistant) -> None: assert state.state == "10498.687664042" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.FEET - state = hass.states.get("sensor.home_wind") + state = hass.states.get("sensor.home_wind_speed") assert state assert state.state == "9.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfSpeed.MILES_PER_HOUR diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index b9e66d51874..1d970e322e4 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -2,6 +2,9 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.accuweather.const import ATTRIBUTION from homeassistant.components.weather import ( ATTR_FORECAST, @@ -27,8 +30,16 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + WeatherEntityFeature, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_UNAVAILABLE, ) -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -41,6 +52,7 @@ from tests.common import ( load_json_array_fixture, load_json_object_fixture, ) +from tests.typing import WebSocketGenerator async def test_weather_without_forecast(hass: HomeAssistant) -> None: @@ -64,6 +76,7 @@ async def test_weather_without_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert ATTR_SUPPORTED_FEATURES not in state.attributes entry = registry.async_get("weather.home") assert entry @@ -90,6 +103,9 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] == WeatherEntityFeature.FORECAST_DAILY + ) forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 2.5 @@ -186,3 +202,81 @@ async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: state = hass.states.get("weather.home") assert state.attributes.get(ATTR_FORECAST_CONDITION) is None + + +async def test_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + await init_integration(hass, forecast=True) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + await init_integration(hass, forecast=True) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": "weather.home", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") + + with patch( + "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", + return_value=current, + ), patch( + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", + return_value=forecast, + ), patch( + "homeassistant.components.accuweather.AccuWeather.requests_remaining", + new_callable=PropertyMock, + return_value=10, + ): + freezer.tick(timedelta(minutes=80) + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot diff --git a/tests/components/advantage_air/test_diagnostics.py b/tests/components/advantage_air/test_diagnostics.py index ebd026c6cc7..01f6d809a49 100644 --- a/tests/components/advantage_air/test_diagnostics.py +++ b/tests/components/advantage_air/test_diagnostics.py @@ -3,11 +3,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import ( - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/aemet/fixtures/station-3195-data.json b/tests/components/aemet/fixtures/station-3195-data.json index b050ee16d67..bbde98b1cb2 100644 --- a/tests/components/aemet/fixtures/station-3195-data.json +++ b/tests/components/aemet/fixtures/station-3195-data.json @@ -1,5 +1,6 @@ [ { + "dv": 100.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T14:00:00", @@ -14,9 +15,12 @@ "ta": 0.1, "tamax": 0.2, "tpr": -0.3, - "rviento": 132.0 + "rviento": 132.0, + "vv": 1.0, + "vmax": 10.0 }, { + "dv": 101.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T15:00:00", @@ -31,9 +35,12 @@ "ta": 0.2, "tamax": 0.3, "tpr": 0.0, - "rviento": 154.0 + "rviento": 154.0, + "vv": 1.1, + "vmax": 10.1 }, { + "dv": 102.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T16:00:00", @@ -48,9 +55,12 @@ "ta": 0.3, "tamax": 0.3, "tpr": 0.0, - "rviento": 177.0 + "rviento": 177.0, + "vv": 1.2, + "vmax": 10.2 }, { + "dv": 103.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T17:00:00", @@ -65,9 +75,12 @@ "ta": 0.1, "tamax": 0.3, "tpr": 0.0, - "rviento": 174.0 + "rviento": 174.0, + "vv": 1.3, + "vmax": 10.3 }, { + "dv": 104.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T18:00:00", @@ -82,9 +95,12 @@ "ta": -0.1, "tamax": 0.1, "tpr": -0.3, - "rviento": 163.0 + "rviento": 163.0, + "vv": 1.4, + "vmax": 10.4 }, { + "dv": 105.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T19:00:00", @@ -99,9 +115,12 @@ "ta": -0.3, "tamax": 0.0, "tpr": -0.5, - "rviento": 79.0 + "rviento": 79.0, + "vv": 1.5, + "vmax": 10.5 }, { + "dv": 106.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T20:00:00", @@ -116,9 +135,12 @@ "ta": -0.6, "tamax": -0.3, "tpr": -0.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 1.6, + "vmax": 10.6 }, { + "dv": 107.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T21:00:00", @@ -133,9 +155,12 @@ "ta": -0.7, "tamax": -0.5, "tpr": -0.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 1.7, + "vmax": 10.7 }, { + "dv": 108.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T22:00:00", @@ -150,9 +175,12 @@ "ta": -0.8, "tamax": -0.7, "tpr": -1.0, - "rviento": 0.0 + "rviento": 0.0, + "vv": 1.8, + "vmax": 10.8 }, { + "dv": 109.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T23:00:00", @@ -167,9 +195,12 @@ "ta": -0.9, "tamax": -0.7, "tpr": -1.0, - "rviento": 0.0 + "rviento": 0.0, + "vv": 1.9, + "vmax": 10.9 }, { + "dv": 110.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T00:00:00", @@ -184,9 +215,12 @@ "ta": -1.0, "tamax": -0.8, "tpr": -1.2, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.0, + "vmax": 11.0 }, { + "dv": 111.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T01:00:00", @@ -201,9 +235,12 @@ "ta": -1.3, "tamax": -1.0, "tpr": -1.4, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.1, + "vmax": 11.1 }, { + "dv": 112.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T02:00:00", @@ -218,9 +255,12 @@ "ta": -1.4, "tamax": -1.3, "tpr": -1.4, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.2, + "vmax": 11.2 }, { + "dv": 113.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T03:00:00", @@ -235,9 +275,12 @@ "ta": -1.4, "tamax": -1.4, "tpr": -1.4, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.3, + "vmax": 11.3 }, { + "dv": 114.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T04:00:00", @@ -252,9 +295,12 @@ "ta": -1.5, "tamax": -1.4, "tpr": -1.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.4, + "vmax": 11.4 }, { + "dv": 115.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T05:00:00", @@ -269,9 +315,12 @@ "ta": -1.5, "tamax": -1.4, "tpr": -1.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.5, + "vmax": 11.5 }, { + "dv": 116.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T06:00:00", @@ -286,9 +335,12 @@ "ta": -1.6, "tamax": -1.5, "tpr": -1.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.6, + "vmax": 11.6 }, { + "dv": 117.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T07:00:00", @@ -303,9 +355,12 @@ "ta": -1.6, "tamax": -1.6, "tpr": -1.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.7, + "vmax": 11.7 }, { + "dv": 118.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T08:00:00", @@ -320,9 +375,12 @@ "ta": -1.6, "tamax": -1.5, "tpr": -1.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.8, + "vmax": 11.8 }, { + "dv": 119.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T09:00:00", @@ -337,9 +395,12 @@ "ta": -1.3, "tamax": -1.3, "tpr": -1.4, - "rviento": 0.0 + "rviento": 0.0, + "vv": 2.9, + "vmax": 11.9 }, { + "dv": 120.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T10:00:00", @@ -354,9 +415,12 @@ "ta": -1.2, "tamax": -1.1, "tpr": -1.4, - "rviento": 0.0 + "rviento": 0.0, + "vv": 3.0, + "vmax": 12.0 }, { + "dv": 121.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T11:00:00", @@ -371,9 +435,12 @@ "ta": -1.0, "tamax": -1.0, "tpr": -1.2, - "rviento": 0.0 + "rviento": 0.0, + "vv": 3.1, + "vmax": 12.1 }, { + "dv": 122.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-09T12:00:00", @@ -388,6 +455,8 @@ "ta": -0.7, "tamax": -0.6, "tpr": -0.7, - "rviento": 0.0 + "rviento": 0.0, + "vv": 3.2, + "vmax": 12.2 } ] diff --git a/tests/components/aemet/fixtures/station-3195.json b/tests/components/aemet/fixtures/station-3195.json deleted file mode 100644 index cfd8c59a7ee..00000000000 --- a/tests/components/aemet/fixtures/station-3195.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/208c3ca3", - "metadatos": "https://opendata.aemet.es/opendata/sh/55c2971b" -} diff --git a/tests/components/aemet/fixtures/station-list-data.json b/tests/components/aemet/fixtures/station-list-data.json index 2507cca7328..d540b0fad1c 100644 --- a/tests/components/aemet/fixtures/station-list-data.json +++ b/tests/components/aemet/fixtures/station-list-data.json @@ -1,5 +1,6 @@ [ { + "dv": 90.0, "idema": "3194U", "lon": -3.724167, "fint": "2021-01-08T14:00:00", @@ -11,9 +12,12 @@ "tamin": 0.6, "ta": 0.9, "tamax": 1.0, - "tpr": 0.6 + "tpr": 0.6, + "vv": 2.0, + "vmax": 2.5 }, { + "dv": 120.0, "idema": "3194Y", "lon": -3.813369, "fint": "2021-01-08T14:00:00", @@ -24,9 +28,12 @@ "hr": 93.0, "tamin": 0.5, "ta": 0.6, - "tamax": 0.6 + "tamax": 0.6, + "vv": 3.0, + "vmax": 3.5 }, { + "dv": 100.0, "idema": "3195", "lon": -3.678095, "fint": "2021-01-08T14:00:00", @@ -41,6 +48,8 @@ "ta": 0.1, "tamax": 0.2, "tpr": -0.3, - "rviento": 132.0 + "rviento": 132.0, + "vv": 1.0, + "vmax": 10.0 } ] diff --git a/tests/components/aemet/fixtures/station-list.json b/tests/components/aemet/fixtures/station-list.json deleted file mode 100644 index 86f79727e7f..00000000000 --- a/tests/components/aemet/fixtures/station-list.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/2c55192f", - "metadatos": "https://opendata.aemet.es/opendata/sh/55c2971b" -} diff --git a/tests/components/aemet/fixtures/town-28065-forecast-daily.json b/tests/components/aemet/fixtures/town-28065-forecast-daily.json deleted file mode 100644 index 41103c1033f..00000000000 --- a/tests/components/aemet/fixtures/town-28065-forecast-daily.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/64e29abb", - "metadatos": "https://opendata.aemet.es/opendata/sh/dfd88b22" -} diff --git a/tests/components/aemet/fixtures/town-28065-forecast-hourly.json b/tests/components/aemet/fixtures/town-28065-forecast-hourly.json deleted file mode 100644 index cdcacfcb6a5..00000000000 --- a/tests/components/aemet/fixtures/town-28065-forecast-hourly.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/18ca1886", - "metadatos": "https://opendata.aemet.es/opendata/sh/93a7c63d" -} diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr new file mode 100644 index 00000000000..3078cab4480 --- /dev/null +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -0,0 +1,1367 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]), + }) +# --- +# name: test_forecast_subscription[daily] + list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]) +# --- +# name: test_forecast_subscription[daily].1 + list([ + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': 30, + 'temperature': 4.0, + 'templow': -4.0, + 'wind_bearing': 45.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 3.0, + 'templow': -7.0, + 'wind_bearing': 0.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-12T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'templow': -13.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-01-13T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -11.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-14T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 6.0, + 'templow': -7.0, + 'wind_bearing': None, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-15T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 5.0, + 'templow': -4.0, + 'wind_bearing': None, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T12:00:00+00:00', + 'precipitation': 3.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T13:00:00+00:00', + 'precipitation': 2.7, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 15.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T14:00:00+00:00', + 'precipitation': 0.6, + 'precipitation_probability': 100, + 'temperature': 0.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 14.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T15:00:00+00:00', + 'precipitation': 0.8, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 20.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T16:00:00+00:00', + 'precipitation': 1.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 14.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T17:00:00+00:00', + 'precipitation': 1.2, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-01-09T18:00:00+00:00', + 'precipitation': 0.4, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 7.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T19:00:00+00:00', + 'precipitation': 0.3, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-01-09T20:00:00+00:00', + 'precipitation': 0.1, + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 135.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 8.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation_probability': 100, + 'temperature': 1.0, + 'wind_bearing': 90.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 10.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'fog', + 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 0.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 9.0, + 'wind_speed': 6.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 12.0, + 'wind_speed': 8.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 11.0, + 'wind_speed': 5.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T06:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': -1.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 13.0, + 'wind_speed': 9.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 18.0, + 'wind_speed': 13.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 31.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T12:00:00+00:00', + 'precipitation_probability': 15, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 22.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 32.0, + 'wind_speed': 20.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 28.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T18:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-10T19:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T20:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 25.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T21:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 24.0, + 'wind_speed': 17.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T22:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-10T23:00:00+00:00', + 'precipitation_probability': None, + 'temperature': 0.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 21.0, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-01-11T00:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 30.0, + 'wind_speed': 19.0, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-01-11T01:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -1.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 27.0, + 'wind_speed': 16.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T02:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 22.0, + 'wind_speed': 12.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T03:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -2.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 17.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T04:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -3.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 11.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T05:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 45.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-01-11T06:00:00+00:00', + 'precipitation_probability': None, + 'temperature': -4.0, + 'wind_bearing': 0.0, + 'wind_gust_speed': 15.0, + 'wind_speed': 10.0, + }), + ]) +# --- diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 59a6993903f..0caacf4e4c0 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -1,17 +1,17 @@ """Define tests for the AEMET OpenData config flow.""" from unittest.mock import AsyncMock, MagicMock, patch +from aemet_opendata.exceptions import AuthError +from freezegun.api import FrozenDateTimeFactory import pytest -import requests_mock from homeassistant import data_entry_flow from homeassistant.components.aemet.const import CONF_STATION_UPDATES, DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util -from .util import aemet_requests_mock +from .util import mock_api_call from tests.common import MockConfigEntry @@ -28,9 +28,10 @@ CONFIG = { async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that the form is served with valid input.""" - with requests_mock.mock() as _m: - aemet_requests_mock(_m) - + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -58,15 +59,18 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_options(hass: HomeAssistant) -> None: +async def test_form_options( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test the form options.""" - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.now", return_value=now), patch( - "homeassistant.util.dt.utcnow", return_value=now - ), requests_mock.mock() as _m: - aemet_requests_mock(_m) - + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): entry = MockConfigEntry( domain=DOMAIN, unique_id="40.30403754--3.72935236", data=CONFIG ) @@ -114,15 +118,18 @@ async def test_form_options(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED -async def test_form_duplicated_id(hass: HomeAssistant) -> None: +async def test_form_duplicated_id( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test setting up duplicated entry.""" - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.now", return_value=now), patch( - "homeassistant.util.dt.utcnow", return_value=now - ), requests_mock.mock() as _m: - aemet_requests_mock(_m) - + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): entry = MockConfigEntry( domain=DOMAIN, unique_id="40.30403754--3.72935236", data=CONFIG ) @@ -136,11 +143,10 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_api_offline(hass: HomeAssistant) -> None: - """Test setting up with api call error.""" +async def test_form_auth_error(hass: HomeAssistant) -> None: + """Test setting up with api auth error.""" mocked_aemet = MagicMock() - - mocked_aemet.get_conventional_observation_stations.return_value = None + mocked_aemet.select_coordinates.side_effect = AuthError with patch( "homeassistant.components.aemet.config_flow.AEMET", diff --git a/tests/components/aemet/test_coordinator.py b/tests/components/aemet/test_coordinator.py new file mode 100644 index 00000000000..067fc30a2c0 --- /dev/null +++ b/tests/components/aemet/test_coordinator.py @@ -0,0 +1,37 @@ +"""Define tests for the AEMET OpenData coordinator.""" +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.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.common import async_fire_time_changed + + +async def test_coordinator_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test error on coordinator update.""" + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=AemetError, + ): + freezer.tick(WEATHER_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("weather.aemet") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index 9db0ffb2bcf..5055575e3fe 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -1,15 +1,14 @@ """Define tests for the AEMET OpenData init.""" from unittest.mock import patch -import requests_mock +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.aemet.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util -from .util import aemet_requests_mock +from .util import mock_api_call from tests.common import MockConfigEntry @@ -21,15 +20,18 @@ CONFIG = { } -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test that the options form.""" - - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.now", return_value=now), patch( - "homeassistant.util.dt.utcnow", return_value=now - ), requests_mock.mock() as _m: - aemet_requests_mock(_m) +async def test_unload_entry( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test (un)loading the AEMET integration.""" + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): config_entry = MockConfigEntry( domain=DOMAIN, unique_id="aemet_unique_id", data=CONFIG ) @@ -42,3 +44,29 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_init_town_not_found( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test TownNotFound when loading the AEMET integration.""" + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "api-key", + CONF_LATITUDE: "0.0", + CONF_LONGITUDE: "0.0", + CONF_NAME: "AEMET", + }, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) is False diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 99bce6b9471..4d61dde34fc 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -1,5 +1,6 @@ """The sensor tests for the AEMET OpenData platform.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.weather import ( ATTR_CONDITION_PARTLYCLOUDY, @@ -12,15 +13,15 @@ import homeassistant.util.dt as dt_util from .util import async_init_integration -async def test_aemet_forecast_create_sensors(hass: HomeAssistant) -> None: +async def test_aemet_forecast_create_sensors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test creation of forecast sensors.""" hass.config.set_time_zone("UTC") - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.now", return_value=now), patch( - "homeassistant.util.dt.utcnow", return_value=now - ): - await async_init_integration(hass) + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) state = hass.states.get("sensor.aemet_daily_forecast_condition") assert state.state == ATTR_CONDITION_PARTLYCLOUDY @@ -73,14 +74,15 @@ async def test_aemet_forecast_create_sensors(hass: HomeAssistant) -> None: assert state is None -async def test_aemet_weather_create_sensors(hass: HomeAssistant) -> None: +async def test_aemet_weather_create_sensors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test creation of weather sensors.""" - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.now", return_value=now), patch( - "homeassistant.util.dt.utcnow", return_value=now - ): - await async_init_integration(hass) + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) state = hass.states.get("sensor.aemet_condition") assert state.state == ATTR_CONDITION_SNOWY diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 30b11876e74..ddcc29698fd 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -1,7 +1,15 @@ """The sensor tests for the AEMET OpenData platform.""" +import datetime from unittest.mock import patch -from homeassistant.components.aemet.const import ATTRIBUTION +from freezegun.api import FrozenDateTimeFactory +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.weather import ( ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_SNOWY, @@ -19,23 +27,71 @@ from homeassistant.components.weather import ( ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from .util import async_init_integration +from .util import async_init_integration, mock_api_call + +from tests.typing import WebSocketGenerator -async def test_aemet_weather(hass: HomeAssistant) -> None: +async def test_aemet_weather( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test states of the weather.""" hass.config.set_time_zone("UTC") - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.now", return_value=now), patch( - "homeassistant.util.dt.utcnow", return_value=now - ): - await async_init_integration(hass) + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + state = hass.states.get("weather.aemet") + assert state + assert state.state == ATTR_CONDITION_SNOWY + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 + assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 + assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h + forecast = state.attributes.get(ATTR_FORECAST)[0] + assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY + assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None + assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 + assert forecast.get(ATTR_FORECAST_TEMP) == 4 + assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert ( + forecast.get(ATTR_FORECAST_TIME) + == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() + ) + assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + + state = hass.states.get("weather.aemet_hourly") + assert state is None + + +async def test_aemet_weather_legacy( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test states of legacy weather.""" + + registry = er.async_get(hass) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "None hourly", + ) + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) state = hass.states.get("weather.aemet_daily") assert state @@ -61,3 +117,88 @@ async def test_aemet_weather(hass: HomeAssistant) -> None: state = hass.states.get("weather.aemet_hourly") assert state is None + + +async def test_forecast_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.aemet", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.aemet", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + await async_init_integration(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": "weather.aemet", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 == snapshot + + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): + freezer.tick(WEATHER_UPDATE_INTERVAL + datetime.timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 == snapshot diff --git a/tests/components/aemet/util.py b/tests/components/aemet/util.py index 991e7459bf6..05417563e2f 100644 --- a/tests/components/aemet/util.py +++ b/tests/components/aemet/util.py @@ -1,93 +1,74 @@ """Tests for the AEMET OpenData integration.""" +from typing import Any +from unittest.mock import patch -import requests_mock +from aemet_opendata.const import ATTR_DATA from homeassistant.components.aemet import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_value_fixture + +FORECAST_DAILY_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-28065-forecast-daily-data.json"), +} + +FORECAST_HOURLY_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-28065-forecast-hourly-data.json"), +} + +STATION_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/station-3195-data.json"), +} + +STATIONS_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/station-list-data.json"), +} + +TOWN_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-id28065.json"), +} + +TOWNS_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-list.json"), +} -def aemet_requests_mock(mock): - """Mock requests performed to AEMET OpenData API.""" - - station_3195_fixture = "aemet/station-3195.json" - station_3195_data_fixture = "aemet/station-3195-data.json" - station_list_fixture = "aemet/station-list.json" - station_list_data_fixture = "aemet/station-list-data.json" - - town_28065_forecast_daily_fixture = "aemet/town-28065-forecast-daily.json" - town_28065_forecast_daily_data_fixture = "aemet/town-28065-forecast-daily-data.json" - town_28065_forecast_hourly_fixture = "aemet/town-28065-forecast-hourly.json" - town_28065_forecast_hourly_data_fixture = ( - "aemet/town-28065-forecast-hourly-data.json" - ) - town_id28065_fixture = "aemet/town-id28065.json" - town_list_fixture = "aemet/town-list.json" - - mock.get( - "https://opendata.aemet.es/opendata/api/observacion/convencional/datos/estacion/3195", - text=load_fixture(station_3195_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/208c3ca3", - text=load_fixture(station_3195_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/observacion/convencional/todas", - text=load_fixture(station_list_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/2c55192f", - text=load_fixture(station_list_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/diaria/28065", - text=load_fixture(town_28065_forecast_daily_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/64e29abb", - text=load_fixture(town_28065_forecast_daily_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/horaria/28065", - text=load_fixture(town_28065_forecast_hourly_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/18ca1886", - text=load_fixture(town_28065_forecast_hourly_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/maestro/municipio/id28065", - text=load_fixture(town_id28065_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/maestro/municipios", - text=load_fixture(town_list_fixture), - ) +def mock_api_call(cmd: str, fetch_data: bool = False) -> dict[str, Any]: + """Mock AEMET OpenData API calls.""" + if cmd == "maestro/municipio/id28065": + return TOWN_DATA_MOCK + if cmd == "maestro/municipios": + return TOWNS_DATA_MOCK + if cmd == "observacion/convencional/datos/estacion/3195": + return STATION_DATA_MOCK + if cmd == "observacion/convencional/todas": + return STATIONS_DATA_MOCK + if cmd == "prediccion/especifica/municipio/diaria/28065": + return FORECAST_DAILY_DATA_MOCK + if cmd == "prediccion/especifica/municipio/horaria/28065": + return FORECAST_HOURLY_DATA_MOCK + return {} -async def async_init_integration( - hass: HomeAssistant, - skip_setup: bool = False, -): +async def async_init_integration(hass: HomeAssistant): """Set up the AEMET OpenData integration in Home Assistant.""" - with requests_mock.mock() as _m: - aemet_requests_mock(_m) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "api-key", + CONF_LATITUDE: "40.30403754", + CONF_LONGITUDE: "-3.72935236", + CONF_NAME: "AEMET", + }, + ) + config_entry.add_to_hass(hass) - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_API_KEY: "mock", - CONF_LATITUDE: "40.30403754", - CONF_LONGITUDE: "-3.72935236", - CONF_NAME: "AEMET", - }, - ) - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index 452df6d9c27..ca26dbaf87f 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -14,6 +14,7 @@ async def init_integration(hass, aioclient_mock) -> MockConfigEntry: entry = MockConfigEntry( domain=DOMAIN, title="Home", + entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id="123-456", data={ "api_key": "foo", diff --git a/tests/components/airly/fixtures/diagnostics_data.json b/tests/components/airly/fixtures/diagnostics_data.json deleted file mode 100644 index 0f225fd4a20..00000000000 --- a/tests/components/airly/fixtures/diagnostics_data.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "PM1": 2.83, - "PM25": 4.37, - "PM10": 6.06, - "CO": 162.49, - "NO2": 16.04, - "O3": 41.52, - "SO2": 13.97, - "PRESSURE": 1019.86, - "HUMIDITY": 68.35, - "TEMPERATURE": 14.37, - "PM25_LIMIT": 15.0, - "PM25_PERCENT": 29.13, - "PM10_LIMIT": 45.0, - "PM10_PERCENT": 14.5, - "CO_LIMIT": 4000, - "CO_PERCENT": 4.06, - "NO2_LIMIT": 25, - "NO2_PERCENT": 64.17, - "O3_LIMIT": 100, - "O3_PERCENT": 41.52, - "SO2_LIMIT": 40, - "SO2_PERCENT": 34.93, - "CAQI": 7.29, - "LEVEL": "very low", - "DESCRIPTION": "Great air here today!", - "ADVICE": "Catch your breath!" -} diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a224ea07d46 --- /dev/null +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'Home', + }), + 'disabled_by': None, + 'domain': 'airly', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Home', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'coordinator_data': dict({ + 'ADVICE': 'Catch your breath!', + 'CAQI': 7.29, + 'CO': 162.49, + 'CO_LIMIT': 4000, + 'CO_PERCENT': 4.06, + 'DESCRIPTION': 'Great air here today!', + 'HUMIDITY': 68.35, + 'LEVEL': 'very low', + 'NO2': 16.04, + 'NO2_LIMIT': 25, + 'NO2_PERCENT': 64.17, + 'O3': 41.52, + 'O3_LIMIT': 100, + 'O3_PERCENT': 41.52, + 'PM1': 2.83, + 'PM10': 6.06, + 'PM10_LIMIT': 45, + 'PM10_PERCENT': 14.5, + 'PM25': 4.37, + 'PM25_LIMIT': 15, + 'PM25_PERCENT': 29.13, + 'PRESSURE': 1019.86, + 'SO2': 13.97, + 'SO2_LIMIT': 40, + 'SO2_PERCENT': 34.93, + 'TEMPERATURE': 14.37, + }), + }) +# --- diff --git a/tests/components/airly/test_diagnostics.py b/tests/components/airly/test_diagnostics.py index 611f7910ae7..7364824e594 100644 --- a/tests/components/airly/test_diagnostics.py +++ b/tests/components/airly/test_diagnostics.py @@ -1,12 +1,11 @@ """Test Airly diagnostics.""" -import json -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -16,30 +15,11 @@ async def test_entry_diagnostics( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass, aioclient_mock) - coordinator_data = json.loads(load_fixture("diagnostics_data.json", "airly")) - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result["config_entry"] == { - "entry_id": entry.entry_id, - "version": 1, - "domain": "airly", - "title": "Home", - "data": { - "latitude": REDACTED, - "longitude": REDACTED, - "name": "Home", - "api_key": REDACTED, - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - } - assert result["coordinator_data"] == coordinator_data + assert result == snapshot diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index f360beb8c51..9b69607e6aa 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -1,7 +1,7 @@ """Test init of Airly integration.""" from typing import Any -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM @@ -11,7 +11,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util.dt import utcnow from . import API_POINT_URL, init_integration @@ -99,7 +98,9 @@ async def test_config_with_turned_off_station( async def test_update_interval( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test correct update interval when the number of configured instances changes.""" REMAINING_REQUESTS = 15 @@ -135,50 +136,47 @@ async def test_update_interval( assert entry.state is ConfigEntryState.LOADED update_interval = set_update_interval(instances, REMAINING_REQUESTS) - future = utcnow() + update_interval - with patch("homeassistant.util.dt.utcnow") as mock_utcnow: - mock_utcnow.return_value = future - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() - # call_count should increase by one because we have one instance configured - assert aioclient_mock.call_count == 2 + # call_count should increase by one because we have one instance configured + assert aioclient_mock.call_count == 2 - # Now we add the second Airly instance - entry = MockConfigEntry( - domain=DOMAIN, - title="Work", - unique_id="66.66-111.11", - data={ - "api_key": "foo", - "latitude": 66.66, - "longitude": 111.11, - "name": "Work", - }, - ) + # Now we add the second Airly instance + entry = MockConfigEntry( + domain=DOMAIN, + title="Work", + unique_id="66.66-111.11", + data={ + "api_key": "foo", + "latitude": 66.66, + "longitude": 111.11, + "name": "Work", + }, + ) - aioclient_mock.get( - "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", - text=load_fixture("valid_station.json", "airly"), - headers=HEADERS, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - instances = 2 + aioclient_mock.get( + "https://airapi.airly.eu/v2/measurements/point?lat=66.660000&lng=111.110000", + text=load_fixture("valid_station.json", "airly"), + headers=HEADERS, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + instances = 2 - assert aioclient_mock.call_count == 3 - assert len(hass.config_entries.async_entries(DOMAIN)) == 2 - assert entry.state is ConfigEntryState.LOADED + assert aioclient_mock.call_count == 3 + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert entry.state is ConfigEntryState.LOADED - update_interval = set_update_interval(instances, REMAINING_REQUESTS) - future = utcnow() + update_interval - mock_utcnow.return_value = future - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + update_interval = set_update_interval(instances, REMAINING_REQUESTS) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() - # call_count should increase by two because we have two instances configured - assert aioclient_mock.call_count == 5 + # call_count should increase by two because we have two instances configured + assert aioclient_mock.call_count == 5 async def test_unload_entry( diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index 47f20ccd883..4e9d1698e8c 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -12,12 +12,15 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(name="config_entry") -def config_entry_fixture(hass, config): +def config_entry_fixture(hass, config, options): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, + version=2, + entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}", data=config, + options=options, ) entry.add_to_hass(hass) return entry @@ -30,7 +33,14 @@ def config_fixture(hass): CONF_API_KEY: "abc123", CONF_LATITUDE: 34.053718, CONF_LONGITUDE: -118.244842, - CONF_RADIUS: 75, + } + + +@pytest.fixture(name="options") +def options_fixture(hass): + """Define a config options data fixture.""" + return { + CONF_RADIUS: 150, } diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8041cb55692 --- /dev/null +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'AQI': 44, + 'Category.Name': 'Good', + 'Category.Number': 1, + 'DateObserved': '2020-12-20', + 'HourObserved': 15, + 'Latitude': '**REDACTED**', + 'Longitude': '**REDACTED**', + 'O3': 0.048, + 'PM10': 12, + 'PM2.5': 8.9, + 'Pollutant': 'O3', + 'ReportingArea': '**REDACTED**', + 'StateCode': '**REDACTED**', + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'airnow', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'options': dict({ + 'radius': 150, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 2, + }), + }) +# --- diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index 5fda5f532a3..f62fc9aee22 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -6,10 +6,13 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.airnow.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, config, setup_airnow) -> None: + +async def test_form(hass: HomeAssistant, config, options, setup_airnow) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -20,6 +23,7 @@ async def test_form(hass: HomeAssistant, config, setup_airnow) -> None: result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["data"] == config + assert result2["options"] == options @pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=InvalidKeyError)]) @@ -85,3 +89,65 @@ async def test_entry_already_exists(hass: HomeAssistant, config, config_entry) - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) assert result2["type"] == "abort" assert result2["reason"] == "already_configured" + + +async def test_config_migration_v2(hass: HomeAssistant, setup_airnow) -> None: + """Test that the config migration from Version 1 to Version 2 works.""" + config_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="AirNow", + data={ + CONF_API_KEY: "1234", + CONF_LATITUDE: 33.6, + CONF_LONGITUDE: -118.1, + CONF_RADIUS: 25, + }, + source=config_entries.SOURCE_USER, + options={CONF_RADIUS: 10}, + unique_id="1234", + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.version == 2 + assert not config_entry.data.get(CONF_RADIUS) + assert config_entry.options.get(CONF_RADIUS) == 25 + + +async def test_options_flow(hass: HomeAssistant, setup_airnow) -> None: + """Test that the options flow works.""" + config_entry = MockConfigEntry( + version=2, + domain=DOMAIN, + title="AirNow", + data={ + CONF_API_KEY: "1234", + CONF_LATITUDE: 33.6, + CONF_LONGITUDE: -118.1, + }, + source=config_entries.SOURCE_USER, + options={CONF_RADIUS: 10}, + unique_id="1234", + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_RADIUS: 25}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert config_entry.options == { + CONF_RADIUS: 25, + } diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py index 38049cfec4b..ecf6acc1c80 100644 --- a/tests/components/airnow/test_diagnostics.py +++ b/tests/components/airnow/test_diagnostics.py @@ -1,5 +1,6 @@ """Test AirNow diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -7,41 +8,14 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, config_entry, hass_client: ClientSessionGenerator, setup_airnow + hass: HomeAssistant, + config_entry, + hass_client: ClientSessionGenerator, + setup_airnow, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "airnow", - "title": REDACTED, - "data": { - "api_key": REDACTED, - "latitude": REDACTED, - "longitude": REDACTED, - "radius": 75, - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "O3": 0.048, - "PM2.5": 8.9, - "HourObserved": 15, - "DateObserved": "2020-12-20", - "StateCode": REDACTED, - "ReportingArea": REDACTED, - "Latitude": REDACTED, - "Longitude": REDACTED, - "PM10": 12, - "AQI": 44, - "Category.Number": 1, - "Category.Name": "Good", - "Pollutant": "O3", - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index 71875b9c4b1..0dd78718a30 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -77,8 +77,11 @@ UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( ) WAVE_DEVICE_INFO = AirthingsDevice( + manufacturer="Airthings AS", hw_version="REV A", sw_version="G-BLE-1.5.3-master+0", + model="Wave Plus", + model_raw="2930", name="Airthings Wave+", identifier="123456", sensors={ diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 1702140864a..bc009f03027 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -25,7 +25,13 @@ from tests.common import MockConfigEntry async def test_bluetooth_discovery(hass: HomeAssistant) -> None: """Test discovery via bluetooth with a valid device.""" with patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble( - AirthingsDevice(name="Airthings Wave+", identifier="123456") + AirthingsDevice( + manufacturer="Airthings AS", + model="Wave Plus", + model_raw="2930", + name="Airthings Wave Plus", + identifier="123456", + ) ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -35,7 +41,9 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" - assert result["description_placeholders"] == {"name": "Airthings Wave+ (123456)"} + assert result["description_placeholders"] == { + "name": "Airthings Wave Plus (123456)" + } with patch_async_setup_entry(): result = await hass.config_entries.flow.async_configure( @@ -43,7 +51,7 @@ async def test_bluetooth_discovery(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Airthings Wave+ (123456)" + assert result["title"] == "Airthings Wave Plus (123456)" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" @@ -100,7 +108,13 @@ async def test_user_setup(hass: HomeAssistant) -> None: "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", return_value=[WAVE_SERVICE_INFO], ), patch_async_ble_device_from_address(WAVE_SERVICE_INFO), patch_airthings_ble( - AirthingsDevice(name="Airthings Wave+", identifier="123456") + AirthingsDevice( + manufacturer="Airthings AS", + model="Wave Plus", + model_raw="2930", + name="Airthings Wave Plus", + identifier="123456", + ) ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -112,7 +126,7 @@ async def test_user_setup(hass: HomeAssistant) -> None: schema = result["data_schema"].schema assert schema.get(CONF_ADDRESS).container == { - "cc:cc:cc:cc:cc:cc": "Airthings Wave+ (123456)" + "cc:cc:cc:cc:cc:cc": "Airthings Wave Plus" } with patch( @@ -125,7 +139,7 @@ async def test_user_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Airthings Wave+ (123456)" + assert result["title"] == "Airthings Wave Plus (123456)" assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py index bdd325d4739..58b8864ea9c 100644 --- a/tests/components/airvisual/conftest.py +++ b/tests/components/airvisual/conftest.py @@ -69,6 +69,7 @@ def config_entry_fixture(hass, config, config_entry_version, integration_type): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, + entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id=async_get_geography_id(config), data={**config, CONF_INTEGRATION_TYPE: integration_type}, options={CONF_SHOW_ON_MAP: True}, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c805c5f9cb7 --- /dev/null +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'city': '**REDACTED**', + 'country': '**REDACTED**', + 'current': dict({ + 'pollution': dict({ + 'aqicn': 18, + 'aqius': 52, + 'maincn': 'p2', + 'mainus': 'p2', + 'ts': '2021-09-04T00:00:00.000Z', + }), + 'weather': dict({ + 'hu': 45, + 'ic': '10d', + 'pr': 999, + 'tp': 23, + 'ts': '2021-09-03T21:00:00.000Z', + 'wd': 252, + 'ws': 0.45, + }), + }), + 'location': dict({ + 'coordinates': '**REDACTED**', + 'type': 'Point', + }), + 'state': '**REDACTED**', + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'integration_type': 'Geographical Location by Latitude/Longitude', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'airvisual', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'options': dict({ + 'show_on_map': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 3, + }), + }) +# --- diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py index 94d22e7f61c..32a083ec985 100644 --- a/tests/components/airvisual/test_diagnostics.py +++ b/tests/components/airvisual/test_diagnostics.py @@ -1,5 +1,6 @@ """Test AirVisual diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,49 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 3, - "domain": "airvisual", - "title": REDACTED, - "data": { - "integration_type": "Geographical Location by Latitude/Longitude", - "api_key": REDACTED, - "latitude": REDACTED, - "longitude": REDACTED, - }, - "options": {"show_on_map": True}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "city": REDACTED, - "state": REDACTED, - "country": REDACTED, - "location": {"type": "Point", "coordinates": REDACTED}, - "current": { - "weather": { - "ts": "2021-09-03T21:00:00.000Z", - "tp": 23, - "pr": 999, - "hu": 45, - "ws": 0.45, - "wd": 252, - "ic": "10d", - }, - "pollution": { - "ts": "2021-09-04T00:00:00.000Z", - "aqius": 52, - "mainus": "p2", - "aqicn": 18, - "maincn": "p2", - }, - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index caff9571812..4376db23366 100644 --- a/tests/components/airvisual_pro/conftest.py +++ b/tests/components/airvisual_pro/conftest.py @@ -24,7 +24,12 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id="XXXXXXX", data=config) + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="6a2b3770e53c28dc1eeb2515e906b0ce", + unique_id="XXXXXXX", + data=config, + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..96cda8e012f --- /dev/null +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'date_and_time': dict({ + 'date': '2022/10/06', + 'time': '16:00:44', + 'timestamp': '1665072044', + }), + 'history': dict({ + }), + 'last_measurement_timestamp': 1665072044, + 'measurements': dict({ + 'aqi_cn': '0', + 'aqi_us': '0', + 'co2': '472', + 'humidity': '57', + 'pm0_1': '0', + 'pm1_0': '0', + 'pm2_5': '0', + 'temperature_C': '23.0', + 'temperature_F': '73.4', + 'voc': '-1', + }), + 'serial_number': '**REDACTED**', + 'settings': dict({ + 'follow_mode': 'station', + 'followed_station': '0', + 'is_aqi_usa': True, + 'is_concentration_showed': True, + 'is_indoor': True, + 'is_lcd_on': True, + 'is_network_time': True, + 'is_temperature_celsius': False, + 'language': 'en-US', + 'lcd_brightness': 80, + 'node_name': 'Office', + 'power_saving': dict({ + '2slots': list([ + dict({ + 'hour_off': 9, + 'hour_on': 7, + }), + dict({ + 'hour_off': 22, + 'hour_on': 18, + }), + ]), + 'mode': 'yes', + 'running_time': 99, + 'yes': list([ + dict({ + 'hour': 8, + 'minute': 0, + }), + dict({ + 'hour': 21, + 'minute': 0, + }), + ]), + }), + 'sensor_mode': dict({ + 'custom_mode_interval': 3, + 'mode': 1, + }), + 'speed_unit': 'mph', + 'timezone': 'America/New York', + 'tvoc_unit': 'ppb', + }), + 'status': dict({ + 'app_version': '1.1826', + 'battery': 100, + 'datetime': 1665072044, + 'device_name': 'AIRVISUAL-XXXXXXX', + 'ip_address': '192.168.1.101', + 'mac_address': '**REDACTED**', + 'model': 20, + 'sensor_life': dict({ + 'pm2_5': 1567924345130, + }), + 'sensor_pm25_serial': '00000005050224011145', + 'sync_time': 250000, + 'system_version': 'KBG63F84', + 'used_memory': 3, + 'wifi_strength': 4, + }), + }), + 'entry': dict({ + 'data': dict({ + 'ip_address': '192.168.1.101', + 'password': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'airvisual_pro', + 'entry_id': '6a2b3770e53c28dc1eeb2515e906b0ce', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'XXXXXXX', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/airvisual_pro/test_diagnostics.py b/tests/components/airvisual_pro/test_diagnostics.py index 5141782e574..7c69a7e636f 100644 --- a/tests/components/airvisual_pro/test_diagnostics.py +++ b/tests/components/airvisual_pro/test_diagnostics.py @@ -1,5 +1,6 @@ """Test AirVisual Pro diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,83 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_airvisual_pro, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "airvisual_pro", - "title": "Mock Title", - "data": {"ip_address": "192.168.1.101", "password": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": "XXXXXXX", - "disabled_by": None, - }, - "data": { - "date_and_time": { - "date": "2022/10/06", - "time": "16:00:44", - "timestamp": "1665072044", - }, - "history": {}, - "measurements": { - "co2": "472", - "humidity": "57", - "pm0_1": "0", - "pm1_0": "0", - "aqi_cn": "0", - "aqi_us": "0", - "pm2_5": "0", - "temperature_C": "23.0", - "temperature_F": "73.4", - "voc": "-1", - }, - "serial_number": REDACTED, - "settings": { - "follow_mode": "station", - "followed_station": "0", - "is_aqi_usa": True, - "is_concentration_showed": True, - "is_indoor": True, - "is_lcd_on": True, - "is_network_time": True, - "is_temperature_celsius": False, - "language": "en-US", - "lcd_brightness": 80, - "node_name": "Office", - "power_saving": { - "2slots": [ - {"hour_off": 9, "hour_on": 7}, - {"hour_off": 22, "hour_on": 18}, - ], - "mode": "yes", - "running_time": 99, - "yes": [{"hour": 8, "minute": 0}, {"hour": 21, "minute": 0}], - }, - "sensor_mode": {"custom_mode_interval": 3, "mode": 1}, - "speed_unit": "mph", - "timezone": "America/New York", - "tvoc_unit": "ppb", - }, - "status": { - "app_version": "1.1826", - "battery": 100, - "datetime": 1665072044, - "device_name": "AIRVISUAL-XXXXXXX", - "ip_address": "192.168.1.101", - "mac_address": REDACTED, - "model": 20, - "sensor_life": {"pm2_5": 1567924345130}, - "sensor_pm25_serial": "00000005050224011145", - "sync_time": 250000, - "system_version": "KBG63F84", - "used_memory": 3, - "wifi_strength": 4, - }, - "last_measurement_timestamp": 1665072044, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b9ab7198148 --- /dev/null +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -0,0 +1,687 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'api_data': dict({ + 'hvac': dict({ + 'systems': list([ + dict({ + 'data': list([ + dict({ + 'air_demand': 0, + 'coldStage': 1, + 'coldStages': 1, + 'coldangle': 0, + 'errors': list([ + ]), + 'floor_demand': 0, + 'heatStage': 1, + 'heatStages': 1, + 'heatangle': 0, + 'humidity': 34, + 'maxTemp': 30, + 'minTemp': 15, + 'mode': 3, + 'modes': list([ + 1, + 4, + 2, + 3, + 5, + ]), + 'name': 'Salon', + 'on': 0, + 'roomTemp': 19.6, + 'setpoint': 19.1, + 'sleep': 0, + 'speed': 0, + 'speeds': 3, + 'systemID': 1, + 'thermos_firmware': '3.51', + 'thermos_radio': 0, + 'thermos_type': 2, + 'units': 0, + 'zoneID': 1, + }), + dict({ + 'air_demand': 1, + 'coldStage': 1, + 'coldStages': 1, + 'coldangle': 2, + 'errors': list([ + ]), + 'floor_demand': 1, + 'heatStage': 3, + 'heatStages': 3, + 'heatangle': 1, + 'humidity': 39, + 'maxTemp': 30, + 'minTemp': 15, + 'mode': 3, + 'name': 'Dorm Ppal', + 'on': 1, + 'roomTemp': 21.1, + 'setpoint': 19.2, + 'sleep': 30, + 'speed': 0, + 'speeds': 2, + 'systemID': 1, + 'thermos_firmware': '3.33', + 'thermos_radio': 1, + 'thermos_type': 4, + 'units': 0, + 'zoneID': 2, + }), + dict({ + 'air_demand': 0, + 'coldStage': 1, + 'coldStages': 1, + 'coldangle': 0, + 'errors': list([ + ]), + 'floor_demand': 0, + 'heatStage': 2, + 'heatStages': 2, + 'heatangle': 0, + 'humidity': 35, + 'maxTemp': 30, + 'minTemp': 15, + 'mode': 3, + 'name': 'Dorm #1', + 'on': 1, + 'roomTemp': 20.8, + 'setpoint': 19.3, + 'sleep': 0, + 'systemID': 1, + 'thermos_firmware': '3.33', + 'thermos_radio': 1, + 'thermos_type': 4, + 'units': 0, + 'zoneID': 3, + }), + dict({ + 'air_demand': 0, + 'coldStage': 1, + 'coldStages': 1, + 'coldangle': 0, + 'errors': list([ + dict({ + 'Zone': 'Low battery', + }), + ]), + 'floor_demand': 0, + 'heatStage': 1, + 'heatStages': 1, + 'heatangle': 0, + 'humidity': 36, + 'maxTemp': 86, + 'minTemp': 59, + 'mode': 3, + 'name': 'Despacho', + 'on': 0, + 'roomTemp': 70.16, + 'setpoint': 66.92, + 'sleep': 0, + 'systemID': 1, + 'thermos_firmware': '3.33', + 'thermos_radio': 1, + 'thermos_type': 4, + 'units': 1, + 'zoneID': 4, + }), + dict({ + 'air_demand': 0, + 'coldStage': 1, + 'coldStages': 1, + 'coldangle': 0, + 'errors': list([ + ]), + 'floor_demand': 0, + 'heatStage': 1, + 'heatStages': 1, + 'heatangle': 0, + 'humidity': 40, + 'maxTemp': 30, + 'minTemp': 15, + 'mode': 3, + 'name': 'Dorm #2', + 'on': 0, + 'roomTemp': 20.5, + 'setpoint': 19.5, + 'sleep': 0, + 'systemID': 1, + 'thermos_firmware': '3.33', + 'thermos_radio': 1, + 'thermos_type': 4, + 'units': 0, + 'zoneID': 5, + }), + ]), + }), + dict({ + 'data': list([ + dict({ + 'coldStage': 1, + 'coldStages': 1, + 'errors': list([ + ]), + 'heatStage': 1, + 'heatStages': 1, + 'humidity': 62, + 'maxTemp': 30, + 'minTemp': 15, + 'on': 0, + 'roomTemp': 22.299999, + 'setpoint': 19, + 'speed': 0, + 'speeds': 4, + 'systemID': 2, + 'units': 0, + 'zoneID': 1, + }), + ]), + }), + dict({ + 'data': list([ + dict({ + 'air_demand': 1, + 'coldStage': 0, + 'coldStages': 0, + 'coolmaxtemp': 90, + 'coolmintemp': 64, + 'coolsetpoint': 73, + 'errors': list([ + ]), + 'floor_demand': 0, + 'heatStage': 0, + 'heatStages': 0, + 'heatmaxtemp': 86, + 'heatmintemp': 50, + 'heatsetpoint': 77, + 'humidity': 0, + 'maxTemp': 90, + 'minTemp': 64, + 'mode': 7, + 'modes': list([ + 4, + 2, + 3, + 5, + 7, + ]), + 'name': 'DKN Plus', + 'on': 1, + 'roomTemp': 71, + 'setpoint': 73, + 'speed': 2, + 'speeds': 5, + 'systemID': 3, + 'units': 1, + 'zoneID': 1, + }), + ]), + }), + ]), + }), + 'version': dict({ + 'version': '1.62', + }), + 'webserver': dict({ + 'mac': '**REDACTED**', + 'wifi_channel': 6, + 'wifi_rssi': -42, + }), + }), + 'config_entry': dict({ + 'data': dict({ + 'host': '192.168.1.100', + 'port': 3000, + }), + 'disabled_by': None, + 'domain': 'airzone', + 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'coord_data': dict({ + 'hot-water': dict({ + 'name': 'Airzone DHW', + 'on': True, + 'operation': 1, + 'operations': list([ + 0, + 1, + 2, + ]), + 'power-mode': False, + 'temp': 43, + 'temp-max': 75, + 'temp-min': 30, + 'temp-set': 45, + 'temp-unit': 0, + }), + 'new-systems': list([ + ]), + 'new-zones': list([ + ]), + 'num-systems': 3, + 'num-zones': 7, + 'systems': dict({ + '1': dict({ + 'available': True, + 'firmware': '3.31', + 'full-name': 'Airzone [1] System', + 'id': 1, + 'master-system-zone': '1:1', + 'master-zone': 1, + 'mode': 3, + 'model': 'C6', + 'modes': list([ + 1, + 4, + 2, + 3, + 5, + ]), + 'problems': False, + }), + '2': dict({ + 'available': True, + 'full-name': 'Airzone [2] System', + 'id': 2, + 'master-system-zone': '2:1', + 'master-zone': 1, + 'mode': 7, + 'modes': list([ + 7, + 1, + ]), + 'problems': False, + }), + '3': dict({ + 'available': True, + 'full-name': 'Airzone [3] System', + 'id': 3, + 'master-system-zone': '3:1', + 'master-zone': 1, + 'mode': 7, + 'modes': list([ + 4, + 2, + 3, + 5, + 7, + 1, + ]), + 'problems': False, + }), + }), + 'version': '1.62', + 'webserver': dict({ + 'mac': '**REDACTED**', + 'wifi-channel': 6, + 'wifi-rssi': -42, + }), + 'zones': dict({ + '1:1': dict({ + 'absolute-temp-max': 30.0, + 'absolute-temp-min': 15.0, + 'action': 6, + 'air-demand': False, + 'available': True, + 'cold-angle': 0, + 'cold-stage': 1, + 'cold-stages': list([ + 0, + 1, + ]), + 'demand': False, + 'double-set-point': False, + 'full-name': 'Airzone [1:1] Salon', + 'heat-angle': 0, + 'heat-stage': 1, + 'heat-stages': list([ + 0, + 1, + ]), + 'humidity': 34, + 'id': 1, + 'master': True, + 'mode': 3, + 'modes': list([ + 1, + 4, + 2, + 3, + 5, + ]), + 'name': 'Salon', + 'on': False, + 'problems': False, + 'sleep': 0, + 'speed': 0, + 'speeds': list([ + 0, + 1, + 2, + 3, + ]), + 'system': 1, + 'temp': 19.6, + 'temp-max': 30.0, + 'temp-min': 15.0, + 'temp-set': 19.1, + 'temp-step': 0.5, + 'temp-unit': 0, + 'thermostat-fw': '3.51', + 'thermostat-model': 'Blueface Zero', + 'thermostat-radio': False, + }), + '1:2': dict({ + 'absolute-temp-max': 30.0, + 'absolute-temp-min': 15.0, + 'action': 4, + 'air-demand': True, + 'available': True, + 'battery-low': False, + 'cold-angle': 2, + 'cold-stage': 1, + 'cold-stages': list([ + 0, + 1, + ]), + 'demand': True, + 'double-set-point': False, + 'floor-demand': True, + 'full-name': 'Airzone [1:2] Dorm Ppal', + 'heat-angle': 1, + 'heat-stage': 3, + 'heat-stages': list([ + 0, + 1, + 2, + 3, + ]), + 'humidity': 39, + 'id': 2, + 'master': False, + 'mode': 3, + 'modes': list([ + 1, + 4, + 2, + 3, + 5, + ]), + 'name': 'Dorm Ppal', + 'on': True, + 'problems': False, + 'sleep': 30, + 'speed': 0, + 'speeds': list([ + 0, + 1, + 2, + ]), + 'system': 1, + 'temp': 21.1, + 'temp-max': 30.0, + 'temp-min': 15.0, + 'temp-set': 19.2, + 'temp-step': 0.5, + 'temp-unit': 0, + 'thermostat-fw': '3.33', + 'thermostat-model': 'Think (Radio)', + 'thermostat-radio': True, + }), + '1:3': dict({ + 'absolute-temp-max': 30.0, + 'absolute-temp-min': 15.0, + 'action': 5, + 'air-demand': False, + 'available': True, + 'battery-low': False, + 'cold-angle': 0, + 'cold-stage': 1, + 'cold-stages': list([ + 0, + 1, + ]), + 'demand': False, + 'double-set-point': False, + 'floor-demand': False, + 'full-name': 'Airzone [1:3] Dorm #1', + 'heat-angle': 0, + 'heat-stage': 2, + 'heat-stages': list([ + 0, + 2, + ]), + 'humidity': 35, + 'id': 3, + 'master': False, + 'mode': 3, + 'modes': list([ + 1, + 4, + 2, + 3, + 5, + ]), + 'name': 'Dorm #1', + 'on': True, + 'problems': False, + 'sleep': 0, + 'system': 1, + 'temp': 20.8, + 'temp-max': 30.0, + 'temp-min': 15.0, + 'temp-set': 19.3, + 'temp-step': 0.5, + 'temp-unit': 0, + 'thermostat-fw': '3.33', + 'thermostat-model': 'Think (Radio)', + 'thermostat-radio': True, + }), + '1:4': dict({ + 'absolute-temp-max': 86.0, + 'absolute-temp-min': 59.0, + 'action': 6, + 'air-demand': False, + 'available': True, + 'battery-low': True, + 'cold-angle': 0, + 'cold-stage': 1, + 'cold-stages': list([ + 0, + 1, + ]), + 'demand': False, + 'double-set-point': False, + 'errors': list([ + 'Low battery', + ]), + 'full-name': 'Airzone [1:4] Despacho', + 'heat-angle': 0, + 'heat-stage': 1, + 'heat-stages': list([ + 0, + 1, + ]), + 'humidity': 36, + 'id': 4, + 'master': False, + 'mode': 3, + 'modes': list([ + 1, + 4, + 2, + 3, + 5, + ]), + 'name': 'Despacho', + 'on': False, + 'problems': True, + 'sleep': 0, + 'system': 1, + 'temp': 70.16, + 'temp-max': 86.0, + 'temp-min': 59.0, + 'temp-set': 66.9, + 'temp-step': 1.0, + 'temp-unit': 1, + 'thermostat-fw': '3.33', + 'thermostat-model': 'Think (Radio)', + 'thermostat-radio': True, + }), + '1:5': dict({ + 'absolute-temp-max': 30.0, + 'absolute-temp-min': 15.0, + 'action': 6, + 'air-demand': False, + 'available': True, + 'battery-low': False, + 'cold-angle': 0, + 'cold-stage': 1, + 'cold-stages': list([ + 0, + 1, + ]), + 'demand': False, + 'double-set-point': False, + 'full-name': 'Airzone [1:5] Dorm #2', + 'heat-angle': 0, + 'heat-stage': 1, + 'heat-stages': list([ + 0, + 1, + ]), + 'humidity': 40, + 'id': 5, + 'master': False, + 'mode': 3, + 'modes': list([ + 1, + 4, + 2, + 3, + 5, + ]), + 'name': 'Dorm #2', + 'on': False, + 'problems': False, + 'sleep': 0, + 'system': 1, + 'temp': 20.5, + 'temp-max': 30.0, + 'temp-min': 15.0, + 'temp-set': 19.5, + 'temp-step': 0.5, + 'temp-unit': 0, + 'thermostat-fw': '3.33', + 'thermostat-model': 'Think (Radio)', + 'thermostat-radio': True, + }), + '2:1': dict({ + 'absolute-temp-max': 30.0, + 'absolute-temp-min': 15.0, + 'action': 6, + 'available': True, + 'cold-stage': 1, + 'cold-stages': list([ + 0, + 1, + ]), + 'demand': False, + 'double-set-point': True, + 'full-name': 'Airzone [2:1] Airzone 2:1', + 'heat-stage': 1, + 'heat-stages': list([ + 0, + 1, + ]), + 'humidity': 62, + 'id': 1, + 'master': True, + 'mode': 7, + 'modes': list([ + 7, + 1, + ]), + 'name': 'Airzone 2:1', + 'on': False, + 'problems': False, + 'speed': 0, + 'speeds': list([ + 0, + 1, + 2, + 3, + 4, + ]), + 'system': 2, + 'temp': 22.3, + 'temp-max': 30.0, + 'temp-min': 15.0, + 'temp-set': 19.0, + 'temp-step': 0.5, + 'temp-unit': 0, + }), + '3:1': dict({ + 'absolute-temp-max': 90.0, + 'absolute-temp-min': 50.0, + 'action': 1, + 'air-demand': True, + 'available': True, + 'cold-stage': 0, + 'cool-temp-max': 90.0, + 'cool-temp-min': 64.0, + 'cool-temp-set': 73.0, + 'demand': True, + 'double-set-point': True, + 'floor-demand': False, + 'full-name': 'Airzone [3:1] DKN Plus', + 'heat-stage': 0, + 'heat-temp-max': 86.0, + 'heat-temp-min': 50.0, + 'heat-temp-set': 77.0, + 'id': 1, + 'master': True, + 'mode': 7, + 'modes': list([ + 4, + 2, + 3, + 5, + 7, + 1, + ]), + 'name': 'DKN Plus', + 'on': True, + 'problems': False, + 'speed': 2, + 'speeds': list([ + 0, + 1, + 2, + 3, + 4, + 5, + ]), + 'system': 3, + 'temp': 71.0, + 'temp-max': 90.0, + 'temp-min': 64.0, + 'temp-set': 73.0, + 'temp-step': 1.0, + 'temp-unit': 1, + }), + }), + }), + }) +# --- diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 3e68c056566..591584da10b 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -1,4 +1,5 @@ """The climate tests for the Airzone platform.""" +import copy from unittest.mock import patch from aioairzone.const import ( @@ -54,6 +55,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util.dt import utcnow from .util import ( + HVAC_DHW_MOCK, HVAC_MOCK, HVAC_SYSTEMS_MOCK, HVAC_WEBSERVER_MOCK, @@ -221,11 +223,14 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP assert state.attributes.get(ATTR_TEMPERATURE) == 22.8 - HVAC_MOCK_CHANGED = {**HVAC_MOCK} + HVAC_MOCK_CHANGED = copy.deepcopy(HVAC_MOCK) HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MAX_TEMP] = 25 HVAC_MOCK_CHANGED[API_SYSTEMS][0][API_DATA][0][API_MIN_TEMP] = 10 with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK_CHANGED, ), patch( @@ -433,10 +438,13 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: state = hass.states.get("climate.salon") assert state.state == HVACMode.FAN_ONLY - HVAC_MOCK_NO_SET_POINT = {**HVAC_MOCK} + HVAC_MOCK_NO_SET_POINT = copy.deepcopy(HVAC_MOCK) del HVAC_MOCK_NO_SET_POINT[API_SYSTEMS][0][API_DATA][0][API_SET_POINT] with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK_NO_SET_POINT, ), patch( diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 5460272e74e..10aaf07885b 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -5,20 +5,29 @@ from unittest.mock import patch from aioairzone.const import API_MAC, API_SYSTEMS from aioairzone.exceptions import ( AirzoneError, + HotWaterNotAvailable, InvalidMethod, InvalidSystem, SystemOutOfRange, ) -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.airzone.config_flow import short_mac from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType -from .util import CONFIG, CONFIG_ID1, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK +from .util import ( + CONFIG, + CONFIG_ID1, + HVAC_DHW_MOCK, + HVAC_MOCK, + HVAC_VERSION_MOCK, + HVAC_WEBSERVER_MOCK, +) from tests.common import MockConfigEntry @@ -40,6 +49,9 @@ async def test_form(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -56,7 +68,7 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} @@ -70,7 +82,7 @@ async def test_form(hass: HomeAssistant) -> None: entry = conf_entries[0] assert entry.state is ConfigEntryState.LOADED - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == f"Airzone {CONFIG[CONF_HOST]}:{CONFIG[CONF_PORT]}" assert result["data"][CONF_HOST] == CONFIG[CONF_HOST] assert result["data"][CONF_PORT] == CONFIG[CONF_PORT] @@ -86,6 +98,9 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", side_effect=InvalidSystem, ) as mock_hvac, patch( @@ -102,7 +117,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_ID: "invalid_system_id"} @@ -113,7 +128,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: result["flow_id"], CONFIG_ID1 ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY await hass.async_block_till_done() @@ -121,7 +136,7 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: entry = conf_entries[0] assert entry.state is ConfigEntryState.LOADED - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert ( result["title"] == f"Airzone {CONFIG_ID1[CONF_HOST]}:{CONFIG_ID1[CONF_PORT]}" @@ -178,13 +193,16 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_DHCP}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovered_connection" with patch( "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -204,7 +222,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["data"] == { CONF_HOST: TEST_IP, CONF_PORT: TEST_PORT, @@ -226,7 +244,7 @@ async def test_dhcp_flow_error(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_DHCP}, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "cannot_connect" @@ -243,7 +261,7 @@ async def test_dhcp_connection_error(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_DHCP}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovered_connection" with patch( @@ -263,6 +281,9 @@ async def test_dhcp_connection_error(hass: HomeAssistant) -> None: "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -288,7 +309,7 @@ async def test_dhcp_connection_error(hass: HomeAssistant) -> None: entry = conf_entries[0] assert entry.state is ConfigEntryState.LOADED - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == f"Airzone {short_mac(HVAC_WEBSERVER_MOCK[API_MAC])}" assert result["data"][CONF_HOST] == TEST_IP assert result["data"][CONF_PORT] == TEST_PORT @@ -309,13 +330,16 @@ async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_DHCP}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovered_connection" with patch( "homeassistant.components.airzone.async_setup_entry", return_value=True, ) as mock_setup_entry, patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", side_effect=InvalidSystem, ) as mock_hvac, patch( @@ -335,7 +359,7 @@ async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None: }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "discovered_connection" assert result["errors"] == {CONF_ID: "invalid_system_id"} @@ -356,7 +380,7 @@ async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None: entry = conf_entries[0] assert entry.state is ConfigEntryState.LOADED - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == f"Airzone {short_mac(DHCP_SERVICE_INFO.macaddress)}" assert result["data"][CONF_HOST] == TEST_IP assert result["data"][CONF_PORT] == TEST_PORT diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index bcfdad8ead8..62f6a15fe35 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -2,7 +2,12 @@ from unittest.mock import patch -from aioairzone.exceptions import AirzoneError, InvalidMethod, SystemOutOfRange +from aioairzone.exceptions import ( + AirzoneError, + HotWaterNotAvailable, + InvalidMethod, + SystemOutOfRange, +) from homeassistant.components.airzone.const import DOMAIN from homeassistant.components.airzone.coordinator import SCAN_INTERVAL @@ -26,6 +31,9 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ) as mock_hvac, patch( diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py index 33f0175bdb7..b64f346f27e 100644 --- a/tests/components/airzone/test_diagnostics.py +++ b/tests/components/airzone/test_diagnostics.py @@ -2,30 +2,13 @@ from unittest.mock import patch -from aioairzone.const import ( - API_DATA, - API_MAC, - API_SYSTEM_ID, - API_SYSTEMS, - API_VERSION, - API_WIFI_RSSI, - AZD_ID, - AZD_MASTER, - AZD_SYSTEM, - AZD_SYSTEMS, - AZD_ZONES, - RAW_HVAC, - RAW_VERSION, - RAW_WEBSERVER, -) +from aioairzone.const import RAW_HVAC, RAW_VERSION, RAW_WEBSERVER +from syrupy import SnapshotAssertion from homeassistant.components.airzone.const import DOMAIN -from homeassistant.components.diagnostics import REDACTED -from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from .util import ( - CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK, @@ -37,7 +20,9 @@ from tests.typing import ClientSessionGenerator async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" await async_init_integration(hass) @@ -52,54 +37,5 @@ async def test_config_entry_diagnostics( RAW_WEBSERVER: HVAC_WEBSERVER_MOCK, }, ): - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - - assert ( - diag["api_data"][RAW_HVAC][API_SYSTEMS][0][API_DATA][0].items() - >= { - API_SYSTEM_ID: HVAC_MOCK[API_SYSTEMS][0][API_DATA][0][API_SYSTEM_ID], - }.items() - ) - - assert ( - diag["api_data"][RAW_VERSION].items() - >= { - API_VERSION: HVAC_VERSION_MOCK[API_VERSION], - }.items() - ) - - assert ( - diag["api_data"][RAW_WEBSERVER].items() - >= { - API_MAC: REDACTED, - API_WIFI_RSSI: HVAC_WEBSERVER_MOCK[API_WIFI_RSSI], - }.items() - ) - - assert ( - diag["config_entry"].items() - >= { - "data": { - CONF_HOST: CONFIG[CONF_HOST], - CONF_PORT: CONFIG[CONF_PORT], - }, - "domain": DOMAIN, - "unique_id": REDACTED, - }.items() - ) - - assert ( - diag["coord_data"][AZD_SYSTEMS]["1"].items() - >= { - AZD_ID: 1, - }.items() - ) - - assert ( - diag["coord_data"][AZD_ZONES]["1:1"].items() - >= { - AZD_ID: 1, - AZD_MASTER: True, - AZD_SYSTEM: 1, - }.items() - ) + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index bb7cb06d1c2..2214e5d07ab 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioairzone.exceptions import InvalidMethod, SystemOutOfRange +from aioairzone.exceptions import HotWaterNotAvailable, InvalidMethod, SystemOutOfRange from homeassistant.components.airzone.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -23,6 +23,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( @@ -45,6 +48,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: ) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + side_effect=HotWaterNotAvailable, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 1d778761ee1..6d94defa004 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,5 +1,6 @@ """The sensor tests for the Airzone platform.""" +import copy from unittest.mock import patch from aioairzone.const import API_DATA, API_SYSTEMS @@ -10,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow from .util import ( + HVAC_DHW_MOCK, HVAC_MOCK, HVAC_SYSTEMS_MOCK, HVAC_VERSION_MOCK, @@ -27,6 +29,10 @@ async def test_airzone_create_sensors( await async_init_integration(hass) + # Hot Water + state = hass.states.get("sensor.airzone_dhw_temperature") + assert state.state == "43" + # WebServer state = hass.states.get("sensor.webserver_rssi") assert state.state == "-42" @@ -82,10 +88,13 @@ async def test_airzone_sensors_availability( await async_init_integration(hass) - HVAC_MOCK_UNAVAILABLE_ZONE = {**HVAC_MOCK} + HVAC_MOCK_UNAVAILABLE_ZONE = copy.deepcopy(HVAC_MOCK) del HVAC_MOCK_UNAVAILABLE_ZONE[API_SYSTEMS][0][API_DATA][1] with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK_UNAVAILABLE_ZONE, ), patch( diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index 4afcaeac232..eb687731eb7 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -3,6 +3,12 @@ from unittest.mock import patch from aioairzone.const import ( + API_ACS_MAX_TEMP, + API_ACS_MIN_TEMP, + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + API_ACS_TEMP, API_AIR_DEMAND, API_COLD_ANGLE, API_COLD_STAGE, @@ -266,6 +272,18 @@ HVAC_MOCK = { ] } +HVAC_DHW_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_TEMP: 43, + API_ACS_SET_POINT: 45, + API_ACS_MAX_TEMP: 75, + API_ACS_MIN_TEMP: 30, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + } +} + HVAC_SYSTEMS_MOCK = { API_SYSTEMS: [ { @@ -295,12 +313,16 @@ async def async_init_integration( config_entry = MockConfigEntry( data=CONFIG, + entry_id="6e7a0798c1734ba81d26ced0e690eaec", domain=DOMAIN, unique_id="airzone_unique_id", ) config_entry.add_to_hass(hass) with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_dhw", + return_value=HVAC_DHW_MOCK, + ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", return_value=HVAC_MOCK, ), patch( diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..94e602ec03b --- /dev/null +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -0,0 +1,236 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'api_data': dict({ + 'devices-config': dict({ + 'device1': dict({ + }), + }), + 'devices-status': dict({ + 'device1': dict({ + }), + }), + 'installations': dict({ + 'installation1': dict({ + 'groups': list([ + dict({ + 'devices': list([ + dict({ + 'device_id': 'device1', + 'ws_id': 'webserver1', + }), + ]), + 'group_id': 'group1', + }), + ]), + 'plugins': dict({ + 'schedules': dict({ + 'calendar_ws_ids': list([ + 'webserver1', + ]), + }), + }), + }), + }), + 'installations-list': dict({ + }), + 'test_cov': dict({ + '1': None, + '2': list([ + 'foo', + 'bar', + ]), + '3': list([ + list([ + 'foo', + 'bar', + ]), + ]), + }), + 'webservers': dict({ + 'webserver1': dict({ + }), + }), + }), + 'config_entry': dict({ + 'data': dict({ + 'id': 'installation1', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'airzone_cloud', + 'entry_id': 'd186e31edb46d64d14b9b2f11f1ebd9f', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'installation1', + 'version': 1, + }), + 'coord_data': dict({ + 'aidoos': dict({ + 'aidoo1': dict({ + 'action': 6, + 'active': False, + 'available': True, + 'id': 'aidoo1', + 'installation': 'installation1', + 'is-connected': True, + 'mode': None, + 'name': 'Bron', + 'power': None, + 'problems': False, + 'temperature': 21.0, + 'temperature-step': 0.5, + 'web-server': '11:22:33:44:55:67', + 'ws-connected': True, + }), + }), + 'groups': dict({ + 'group1': dict({ + 'action': 6, + 'active': True, + 'available': True, + 'humidity': 27, + 'installation': 'installation1', + 'mode': 0, + 'name': 'Group', + 'num-devices': 2, + 'power': None, + 'systems': list([ + 'system1', + ]), + 'temperature': 22.5, + 'temperature-step': 0.5, + 'zones': list([ + 'zone1', + 'zone2', + ]), + }), + 'grp2': dict({ + 'action': 6, + 'active': False, + 'aidoos': list([ + 'aidoo1', + ]), + 'available': True, + 'installation': 'installation1', + 'mode': 0, + 'name': 'Aidoo Group', + 'num-devices': 1, + 'power': None, + 'temperature': 21.0, + 'temperature-step': 0.5, + }), + }), + 'installations': dict({ + 'installation1': dict({ + 'id': 'installation1', + 'name': 'House', + 'web-servers': list([ + 'webserver1', + '11:22:33:44:55:67', + ]), + }), + }), + 'systems': dict({ + 'system1': dict({ + 'available': True, + 'errors': list([ + dict({ + '_id': 'error-id', + }), + ]), + 'id': 'system1', + 'installation': 'installation1', + 'is-connected': True, + 'mode': None, + 'name': 'System 1', + 'problems': True, + 'system': 1, + 'web-server': 'webserver1', + 'ws-connected': True, + }), + }), + 'web-servers': dict({ + '11:22:33:44:55:67': dict({ + 'available': True, + 'connection-date': '2023-05-24 17:00:52 +0200', + 'disconnection-date': '2023-05-24 17:00:25 +0200', + 'firmware': '3.13', + 'id': '11:22:33:44:55:67', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:67', + 'type': 'ws_aidoo', + 'wifi-channel': 1, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -77, + 'wifi-ssid': 'Wifi', + }), + 'webserver1': dict({ + 'available': True, + 'connection-date': '2023-05-07T12:55:51.000Z', + 'disconnection-date': '2023-01-01T22:26:55.376Z', + 'firmware': '3.44', + 'id': 'webserver1', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:66', + 'type': 'ws_az', + 'wifi-channel': 36, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -56, + 'wifi-ssid': 'Wifi', + }), + }), + 'zones': dict({ + 'zone1': dict({ + 'action': 6, + 'active': True, + 'available': True, + 'humidity': 30, + 'id': 'zone1', + 'installation': 'installation1', + 'is-connected': True, + 'master': None, + 'mode': None, + 'name': 'Salon', + 'power': None, + 'problems': False, + 'system': 1, + 'system-id': 'system1', + 'temperature': 20.0, + 'temperature-step': 0.5, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 1, + }), + 'zone2': dict({ + 'action': 6, + 'active': False, + 'available': True, + 'humidity': 24, + 'id': 'zone2', + 'installation': 'installation1', + 'is-connected': True, + 'master': None, + 'mode': None, + 'name': 'Dormitorio', + 'power': None, + 'problems': False, + 'system': 1, + 'system-id': 'system1', + 'temperature': 25.0, + 'temperature-step': 0.5, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 2, + }), + }), + }), + }) +# --- diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index 14f7a078156..a1b5d5319c0 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -1,5 +1,7 @@ """The binary sensor tests for the Airzone Cloud platform.""" +from aioairzone_cloud.const import API_OLD_ID + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -20,6 +22,16 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.bron_running") assert state.state == STATE_OFF + # Systems + state = hass.states.get("binary_sensor.system_1_problem") + assert state.state == STATE_ON + assert state.attributes.get("errors") == [ + { + API_OLD_ID: "error-id", + }, + ] + assert state.attributes.get("warnings") is None + # Zones state = hass.states.get("binary_sensor.dormitorio_problem") assert state.state == STATE_OFF diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index 6c8ae366518..8bef70501e7 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -8,22 +8,16 @@ from aioairzone_cloud.const import ( API_GROUP_ID, API_GROUPS, API_WS_ID, - AZD_AIDOOS, - AZD_GROUPS, - AZD_INSTALLATIONS, - AZD_SYSTEMS, - AZD_WEBSERVERS, - AZD_ZONES, RAW_DEVICES_CONFIG, RAW_DEVICES_STATUS, RAW_INSTALLATIONS, RAW_INSTALLATIONS_LIST, RAW_WEBSERVERS, ) +from syrupy import SnapshotAssertion from homeassistant.components.airzone_cloud.const import DOMAIN -from homeassistant.components.diagnostics import REDACTED -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from .util import CONFIG, WS_ID, async_init_integration @@ -78,7 +72,9 @@ RAW_DATA_MOCK = { async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" await async_init_integration(hass) @@ -89,40 +85,5 @@ async def test_config_entry_diagnostics( "homeassistant.components.airzone_cloud.AirzoneCloudApi.raw_data", return_value=RAW_DATA_MOCK, ): - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - - assert list(diag["api_data"]) >= list(RAW_DATA_MOCK) - assert "dev1" not in diag["api_data"][RAW_DEVICES_CONFIG] - assert "device1" in diag["api_data"][RAW_DEVICES_CONFIG] - assert ( - diag["api_data"][RAW_INSTALLATIONS]["installation1"][API_GROUPS][0][ - API_GROUP_ID - ] - == "group1" - ) - assert "inst1" not in diag["api_data"][RAW_INSTALLATIONS] - assert "installation1" in diag["api_data"][RAW_INSTALLATIONS] - assert WS_ID not in diag["api_data"][RAW_WEBSERVERS] - assert "webserver1" in diag["api_data"][RAW_WEBSERVERS] - - assert ( - diag["config_entry"].items() - >= { - "data": { - CONF_ID: "installation1", - CONF_PASSWORD: REDACTED, - CONF_USERNAME: REDACTED, - }, - "domain": DOMAIN, - "unique_id": "installation1", - }.items() - ) - - assert list(diag["coord_data"]) >= [ - AZD_AIDOOS, - AZD_GROUPS, - AZD_INSTALLATIONS, - AZD_SYSTEMS, - AZD_WEBSERVERS, - AZD_ZONES, - ] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 0c26755f948..8fd7da06853 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -25,6 +25,7 @@ from aioairzone_cloud.const import ( API_LOCAL_TEMP, API_META, API_NAME, + API_OLD_ID, API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, @@ -175,7 +176,11 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: } if device.get_id() == "system1": return { - API_ERRORS: [], + API_ERRORS: [ + { + API_OLD_ID: "error-id", + }, + ], API_IS_CONNECTED: True, API_WS_CONNECTED: True, API_WARNINGS: [], @@ -223,6 +228,7 @@ async def async_init_integration( config_entry = MockConfigEntry( data=CONFIG, + entry_id="d186e31edb46d64d14b9b2f11f1ebd9f", domain=DOMAIN, unique_id=CONFIG[CONF_ID], ) diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py index 53a836f97f3..4cbe112af49 100644 --- a/tests/components/alexa/test_common.py +++ b/tests/components/alexa/test_common.py @@ -4,7 +4,7 @@ from uuid import uuid4 import pytest -from homeassistant.components.alexa import config, smart_home, smart_home_http +from homeassistant.components.alexa import config, smart_home from homeassistant.components.alexa.const import CONF_ENDPOINT, CONF_FILTER, CONF_LOCALE from homeassistant.core import Context, callback from homeassistant.helpers import entityfilter @@ -16,7 +16,7 @@ TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token" TEST_LOCALE = "en-US" -class MockConfig(smart_home_http.AlexaConfig): +class MockConfig(smart_home.AlexaConfig): """Mock Alexa config.""" entity_config = { diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index 0a4acda79f5..c6c2b3cc421 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -14,7 +14,6 @@ SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000" APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe" REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" -# pylint: disable=invalid-name calls = [] NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 03546c0ed22..c63825b3c12 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -19,7 +19,6 @@ REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000" AUTHORITY_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.ZODIAC" BUILTIN_AUTH_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.TEST" -# pylint: disable=invalid-name calls = [] NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 477e7884e4f..c42ea0a0f6a 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,14 +1,14 @@ """Test for smart home alexa support.""" from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.alexa import messages, smart_home +from homeassistant.components.alexa import smart_home, state_report import homeassistant.components.camera as camera from homeassistant.components.cover import CoverDeviceClass from homeassistant.components.media_player import MediaPlayerEntityFeature -import homeassistant.components.vacuum as vacuum +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import STATE_UNKNOWN, UnitOfTemperature from homeassistant.core import Context, Event, HomeAssistant @@ -29,6 +29,7 @@ from .test_common import ( ) from tests.common import async_capture_events, async_mock_service +from tests.typing import ClientSessionGenerator @pytest.fixture @@ -58,7 +59,7 @@ def test_create_api_message_defaults(hass: HomeAssistant) -> None: """Create an API message response of a request with defaults.""" request = get_new_request("Alexa.PowerController", "TurnOn", "switch#xy") directive_header = request["directive"]["header"] - directive = messages.AlexaDirective(request) + directive = state_report.AlexaDirective(request) msg = directive.response(payload={"test": 3})._response @@ -84,7 +85,7 @@ def test_create_api_message_special() -> None: request = get_new_request("Alexa.PowerController", "TurnOn") directive_header = request["directive"]["header"] directive_header.pop("correlationToken") - directive = messages.AlexaDirective(request) + directive = state_report.AlexaDirective(request) msg = directive.response("testName", "testNameSpace")._response @@ -2797,20 +2798,13 @@ async def test_disabled(hass: HomeAssistant) -> None: hass.states.async_set("switch.test", "on", {"friendly_name": "Test switch"}) request = get_new_request("Alexa.PowerController", "TurnOn", "switch#test") - call_switch = async_mock_service(hass, "switch", "turn_on") + async_mock_service(hass, "switch", "turn_on") - msg = await smart_home.async_handle_message( - hass, get_default_config(hass), request, enabled=False - ) - await hass.async_block_till_done() - - assert "event" in msg - msg = msg["event"] - - assert not call_switch - assert msg["header"]["name"] == "ErrorResponse" - assert msg["header"]["namespace"] == "Alexa" - assert msg["payload"]["type"] == "BRIDGE_UNREACHABLE" + with pytest.raises(AssertionError): + await smart_home.async_handle_message( + hass, get_default_config(hass), request, enabled=False + ) + await hass.async_block_till_done() async def test_endpoint_good_health(hass: HomeAssistant) -> None: @@ -3878,12 +3872,12 @@ async def test_vacuum_discovery(hass: HomeAssistant) -> None: "docked", { "friendly_name": "Test vacuum 1", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_RETURN_HOME - | vacuum.SUPPORT_PAUSE, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.PAUSE, }, ) appliance = await discovery_test(device, hass) @@ -3919,12 +3913,12 @@ async def test_vacuum_fan_speed(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 2", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_PAUSE - | vacuum.SUPPORT_FAN_SPEED, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.FAN_SPEED, "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], "fan_speed": "medium", }, @@ -4048,12 +4042,12 @@ async def test_vacuum_pause(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 3", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_PAUSE - | vacuum.SUPPORT_FAN_SPEED, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.FAN_SPEED, "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], "fan_speed": "medium", }, @@ -4086,12 +4080,12 @@ async def test_vacuum_resume(hass: HomeAssistant) -> None: "docked", { "friendly_name": "Test vacuum 4", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_STOP - | vacuum.SUPPORT_PAUSE - | vacuum.SUPPORT_FAN_SPEED, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.STOP + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.FAN_SPEED, "fan_speed_list": ["off", "low", "medium", "high", "turbo", "super_sucker"], "fan_speed": "medium", }, @@ -4114,9 +4108,9 @@ async def test_vacuum_discovery_no_turn_on(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 5", - "supported_features": vacuum.SUPPORT_TURN_OFF - | vacuum.SUPPORT_START - | vacuum.SUPPORT_RETURN_HOME, + "supported_features": VacuumEntityFeature.TURN_OFF + | VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME, }, ) appliance = await discovery_test(device, hass) @@ -4144,9 +4138,9 @@ async def test_vacuum_discovery_no_turn_off(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 6", - "supported_features": vacuum.SUPPORT_TURN_ON - | vacuum.SUPPORT_START - | vacuum.SUPPORT_RETURN_HOME, + "supported_features": VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME, }, ) appliance = await discovery_test(device, hass) @@ -4175,7 +4169,8 @@ async def test_vacuum_discovery_no_turn_on_or_off(hass: HomeAssistant) -> None: "cleaning", { "friendly_name": "Test vacuum 7", - "supported_features": vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME, + "supported_features": VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME, }, ) appliance = await discovery_test(device, hass) @@ -4291,7 +4286,7 @@ async def test_initialize_camera_stream( msg = await smart_home.async_handle_message( hass, get_default_config(hass), request ) - await hass.async_block_till_done() + await hass.async_stop() assert "event" in msg response = msg["event"] @@ -4372,3 +4367,28 @@ async def test_api_message_sets_authorized(hass: HomeAssistant) -> None: config._store.set_authorized.assert_not_called() await smart_home.async_handle_message(hass, config, msg) config._store.set_authorized.assert_called_once_with(True) + + +async def test_alexa_config( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test all methods of the AlexaConfig class.""" + config = { + "filter": entityfilter.FILTER_SCHEMA({"include_domains": ["sensor"]}), + } + test_config = smart_home.AlexaConfig(hass, config) + await test_config.async_initialize() + assert not test_config.supports_auth + assert not test_config.should_report_state + assert test_config.endpoint is None + assert test_config.entity_config == {} + assert test_config.user_identifier() == "" + assert test_config.locale is None + assert test_config.should_expose("sensor.test") + assert not test_config.should_expose("switch.test") + with patch.object(test_config, "_auth", AsyncMock()): + test_config._auth.async_invalidate_access_token = MagicMock() + test_config.async_invalidate_access_token() + assert len(test_config._auth.async_invalidate_access_token.mock_calls) + await test_config.async_accept_grant("grant_code") + test_config._auth.async_do_auth.assert_called_once_with("grant_code") diff --git a/tests/components/alexa/test_smart_home_http.py b/tests/components/alexa/test_smart_home_http.py index b279e75b634..b0f78e958d7 100644 --- a/tests/components/alexa/test_smart_home_http.py +++ b/tests/components/alexa/test_smart_home_http.py @@ -1,8 +1,11 @@ """Test Smart Home HTTP endpoints.""" from http import HTTPStatus import json +from typing import Any -from homeassistant.components.alexa import DOMAIN, smart_home_http +import pytest + +from homeassistant.components.alexa import DOMAIN, smart_home from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -19,19 +22,31 @@ async def do_http_discovery(config, hass, hass_client): request = get_new_request("Alexa.Discovery", "Discover") response = await http_client.post( - smart_home_http.SMART_HOME_HTTP_ENDPOINT, + smart_home.SMART_HOME_HTTP_ENDPOINT, data=json.dumps(request), headers={"content-type": CONTENT_TYPE_JSON}, ) return response +@pytest.mark.parametrize( + "config", + [ + {"alexa": {"smart_home": None}}, + { + "alexa": { + "smart_home": { + "client_id": "someclientid", + "client_secret": "verysecret", + } + } + }, + ], +) async def test_http_api( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, config: dict[str, Any] ) -> None: """With `smart_home:` HTTP API is exposed.""" - config = {"alexa": {"smart_home": None}} - response = await do_http_discovery(config, hass, hass_client) response_data = await response.json() diff --git a/tests/components/ambient_station/conftest.py b/tests/components/ambient_station/conftest.py index aa849922b34..ab5eb6239c8 100644 --- a/tests/components/ambient_station/conftest.py +++ b/tests/components/ambient_station/conftest.py @@ -28,7 +28,11 @@ def config_fixture(hass): @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, data=config) + entry = MockConfigEntry( + domain=DOMAIN, + data=config, + entry_id="382cf7643f016fd48b3fe52163fe8877", + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..4b231660c4b --- /dev/null +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'app_key': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ambient_station', + 'entry_id': '382cf7643f016fd48b3fe52163fe8877', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 2, + }), + 'stations': dict({ + 'devices': list([ + dict({ + 'apiKey': '**REDACTED**', + 'info': dict({ + 'location': '**REDACTED**', + 'name': 'Side Yard', + }), + 'lastData': dict({ + 'baromabsin': 25.016, + 'baromrelin': 29.953, + 'batt_co2': 1, + 'dailyrainin': 0, + 'date': '2022-01-19T22:38:00.000Z', + 'dateutc': 1642631880000, + 'deviceId': '**REDACTED**', + 'dewPoint': 17.75, + 'dewPointin': 37, + 'eventrainin': 0, + 'feelsLike': 21, + 'feelsLikein': 69.1, + 'hourlyrainin': 0, + 'humidity': 87, + 'humidityin': 29, + 'lastRain': '2022-01-07T19:45:00.000Z', + 'maxdailygust': 9.2, + 'monthlyrainin': 0.409, + 'solarradiation': 11.62, + 'tempf': 21, + 'tempinf': 70.9, + 'totalrainin': 35.398, + 'tz': '**REDACTED**', + 'uv': 0, + 'weeklyrainin': 0, + 'winddir': 25, + 'windgustmph': 1.1, + 'windspeedmph': 0.2, + }), + 'macAddress': '**REDACTED**', + }), + ]), + 'method': 'subscribe', + }), + }) +# --- diff --git a/tests/components/ambient_station/test_diagnostics.py b/tests/components/ambient_station/test_diagnostics.py index 61e974f4d0b..4c7a0f66f6a 100644 --- a/tests/components/ambient_station/test_diagnostics.py +++ b/tests/components/ambient_station/test_diagnostics.py @@ -1,6 +1,7 @@ """Test Ambient PWS diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.components.ambient_station import DOMAIN -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -13,62 +14,12 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, data_station, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" ambient = hass.data[DOMAIN][config_entry.entry_id] ambient.stations = data_station - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 2, - "domain": "ambient_station", - "title": REDACTED, - "data": {"api_key": REDACTED, "app_key": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "stations": { - "devices": [ - { - "macAddress": REDACTED, - "lastData": { - "dateutc": 1642631880000, - "tempinf": 70.9, - "humidityin": 29, - "baromrelin": 29.953, - "baromabsin": 25.016, - "tempf": 21, - "humidity": 87, - "winddir": 25, - "windspeedmph": 0.2, - "windgustmph": 1.1, - "maxdailygust": 9.2, - "hourlyrainin": 0, - "eventrainin": 0, - "dailyrainin": 0, - "weeklyrainin": 0, - "monthlyrainin": 0.409, - "totalrainin": 35.398, - "solarradiation": 11.62, - "uv": 0, - "batt_co2": 1, - "feelsLike": 21, - "dewPoint": 17.75, - "feelsLikein": 69.1, - "dewPointin": 37, - "lastRain": "2022-01-07T19:45:00.000Z", - "deviceId": REDACTED, - "tz": REDACTED, - "date": "2022-01-19T22:38:00.000Z", - }, - "info": {"name": "Side Yard", "location": REDACTED}, - "apiKey": REDACTED, - } - ], - "method": "subscribe", - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 5ba9d60996b..116529b02a4 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -81,7 +81,6 @@ async def test_api_state_change( assert hass.states.get("test.test").state == "debug_state_change2" -# pylint: disable=invalid-name async def test_api_state_change_of_non_existing_entity( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -97,7 +96,6 @@ async def test_api_state_change_of_non_existing_entity( assert hass.states.get("test_entity.that_does_not_exist").state == new_state -# pylint: disable=invalid-name async def test_api_state_change_with_bad_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -109,7 +107,6 @@ async def test_api_state_change_with_bad_data( assert resp.status == HTTPStatus.BAD_REQUEST -# pylint: disable=invalid-name async def test_api_state_change_to_zero_value( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -127,7 +124,6 @@ async def test_api_state_change_to_zero_value( assert resp.status == HTTPStatus.OK -# pylint: disable=invalid-name async def test_api_state_change_push( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -154,7 +150,6 @@ async def test_api_state_change_push( assert len(events) == 1 -# pylint: disable=invalid-name async def test_api_fire_event_with_no_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -174,7 +169,6 @@ async def test_api_fire_event_with_no_data( assert len(test_value) == 1 -# pylint: disable=invalid-name async def test_api_fire_event_with_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -199,7 +193,6 @@ async def test_api_fire_event_with_data( assert len(test_value) == 1 -# pylint: disable=invalid-name async def test_api_fire_event_with_invalid_json( hass: HomeAssistant, mock_api_client: TestClient ) -> None: diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index 693cdc685c9..ba32951efe4 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockEntityPlatform @@ -94,6 +95,8 @@ async def player_setup_fixture(hass, state_1, state_2, client): if zone == 2: return state_2 + await async_setup_component(hass, "homeassistant", {}) + with patch("homeassistant.components.arcam_fmj.Client", return_value=client), patch( "homeassistant.components.arcam_fmj.media_player.State", side_effect=state_mock ), patch("homeassistant.components.arcam_fmj._run_client", return_value=None): diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py index 2607ab817df..9287e8dbc18 100644 --- a/tests/components/arcam_fmj/test_media_player.py +++ b/tests/components/arcam_fmj/test_media_player.py @@ -2,14 +2,20 @@ from math import isclose from unittest.mock import ANY, PropertyMock, patch -from arcam.fmj import DecodeMode2CH, DecodeModeMCH, SourceCodes +from arcam.fmj import ConnectionFailed, DecodeMode2CH, DecodeModeMCH, SourceCodes import pytest +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_VOLUME_LEVEL, ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, SERVICE_SELECT_SOURCE, + SERVICE_VOLUME_SET, MediaType, ) from homeassistant.const import ( @@ -20,6 +26,7 @@ from homeassistant.const import ( ATTR_NAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import MOCK_HOST, MOCK_UUID @@ -106,12 +113,33 @@ async def test_name(player) -> None: assert data.attributes["friendly_name"] == "Zone 1" -async def test_update(player, state) -> None: +async def test_update(hass: HomeAssistant, player_setup: str, state) -> None: """Test update.""" - await update(player, force_refresh=True) + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: player_setup}, + blocking=True, + ) state.update.assert_called_with() +async def test_update_lost( + hass: HomeAssistant, player_setup: str, state, caplog: pytest.LogCaptureFixture +) -> None: + """Test update, with connection loss is ignored.""" + state.update.side_effect = ConnectionFailed() + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: player_setup}, + blocking=True, + ) + state.update.assert_called_with() + assert "Connection lost during update" in caplog.text + + @pytest.mark.parametrize( ("source", "value"), [("PVR", SourceCodes.PVR), ("BD", SourceCodes.BD), ("INVALID", None)], @@ -200,9 +228,9 @@ async def test_sound_mode_list(player, state, modes, modes_enum) -> None: async def test_is_volume_muted(player, state) -> None: """Test muted.""" state.get_mute.return_value = True - assert player.is_volume_muted is True # pylint: disable=singleton-comparison + assert player.is_volume_muted is True state.get_mute.return_value = False - assert player.is_volume_muted is False # pylint: disable=singleton-comparison + assert player.is_volume_muted is False state.get_mute.return_value = None assert player.is_volume_muted is None @@ -220,12 +248,37 @@ async def test_volume_level(player, state) -> None: @pytest.mark.parametrize(("volume", "call"), [(0.0, 0), (0.5, 50), (1.0, 99)]) -async def test_set_volume_level(player, state, volume, call) -> None: +async def test_set_volume_level( + hass: HomeAssistant, player_setup: str, state, volume, call +) -> None: """Test setting volume.""" - await player.async_set_volume_level(volume) + + await hass.services.async_call( + "media_player", + SERVICE_VOLUME_SET, + service_data={ATTR_ENTITY_ID: player_setup, ATTR_MEDIA_VOLUME_LEVEL: volume}, + blocking=True, + ) + state.set_volume.assert_called_with(call) +async def test_set_volume_level_lost( + hass: HomeAssistant, player_setup: str, state +) -> None: + """Test setting volume, with a lost connection.""" + + state.set_volume.side_effect = ConnectionFailed() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + SERVICE_VOLUME_SET, + service_data={ATTR_ENTITY_ID: player_setup, ATTR_MEDIA_VOLUME_LEVEL: 0.0}, + blocking=True, + ) + + @pytest.mark.parametrize( ("source", "media_content_type"), [ diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 5aa760cc606..d2ec3553cf0 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock import pytest -from homeassistant.components import stt, tts +from homeassistant.components import stt, tts, wake_word from homeassistant.components.assist_pipeline import DOMAIN from homeassistant.components.assist_pipeline.pipeline import ( PipelineData, @@ -174,6 +174,40 @@ class MockSttPlatform(MockPlatform): self.async_get_engine = async_get_engine +class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): + """Mock wake word entity.""" + + fail_process_audio = False + url_path = "wake_word.test" + _attr_name = "test" + + @property + def supported_wake_words(self) -> list[wake_word.WakeWord]: + """Return a list of supported wake words.""" + return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + + async def _async_process_audio_stream( + self, stream: AsyncIterable[tuple[bytes, int]] + ) -> wake_word.DetectionResult | None: + """Try to detect wake word(s) in an audio stream with timestamps.""" + async for chunk, timestamp in stream: + if chunk.startswith(b"wake word"): + return wake_word.DetectionResult( + ww_id=self.supported_wake_words[0].ww_id, + timestamp=timestamp, + queued_audio=[(b"queued audio", 0)], + ) + + # Not detected + return None + + +@pytest.fixture +async def mock_wake_word_provider_entity(hass) -> MockWakeWordEntity: + """Mock wake word provider.""" + return MockWakeWordEntity() + + class MockFlow(ConfigFlow): """Test flow.""" @@ -193,6 +227,7 @@ async def init_supporting_components( mock_stt_provider: MockSttProvider, mock_stt_provider_entity: MockSttProviderEntity, mock_tts_provider: MockTTSProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, config_flow_fixture, ): """Initialize relevant components with empty configs.""" @@ -201,14 +236,18 @@ async def init_supporting_components( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setup(config_entry, stt.DOMAIN) + await hass.config_entries.async_forward_entry_setups( + config_entry, [stt.DOMAIN, wake_word.DOMAIN] + ) return True async def async_unload_entry_init( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Unload up test config entry.""" - await hass.config_entries.async_forward_entry_unload(config_entry, stt.DOMAIN) + await hass.config_entries.async_unload_platforms( + config_entry, [stt.DOMAIN, wake_word.DOMAIN] + ) return True async def async_setup_entry_stt_platform( @@ -219,6 +258,14 @@ async def init_supporting_components( """Set up test stt platform via config entry.""" async_add_entities([mock_stt_provider_entity]) + async def async_setup_entry_wake_word_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test wake word platform via config entry.""" + async_add_entities([mock_wake_word_provider_entity]) + mock_integration( hass, MockModule( @@ -242,6 +289,13 @@ async def init_supporting_components( async_setup_entry=async_setup_entry_stt_platform, ), ) + mock_platform( + hass, + "test.wake_word", + MockPlatform( + async_setup_entry=async_setup_entry_wake_word_platform, + ), + ) mock_platform(hass, "test.config_flow") assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index d8858cec4b6..7c1cf0e2b2d 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -266,3 +266,126 @@ }), ]) # --- +# name: test_pipeline_from_audio_stream_wake_word + list([ + dict({ + 'data': dict({ + 'language': 'en', + 'pipeline': , + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'wake_word_output': dict({ + 'timestamp': 2000, + 'ww_id': 'test_ww', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': , + 'channel': , + 'codec': , + 'format': , + 'language': 'en-US', + 'sample_rate': , + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'timestamp': 0, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'timestamp': 1500, + }), + 'type': , + }), + dict({ + 'data': dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'intent_output': 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", + }), + }), + }), + }), + }), + 'type': , + }), + dict({ + 'data': dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }), + 'type': , + }), + dict({ + 'data': dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }), + 'type': , + }), + dict({ + 'data': None, + 'type': , + }), + ]) +# --- diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 12a4d766f06..57fbe5f4908 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -77,6 +77,9 @@ }), }) # --- +# name: test_audio_pipeline.7 + None +# --- # name: test_audio_pipeline_debug dict({ 'language': 'en', @@ -155,6 +158,252 @@ }), }) # --- +# name: test_audio_pipeline_debug.7 + None +# --- +# name: test_audio_pipeline_no_wake_word_engine + dict({ + 'code': 'wake-engine-missing', + 'message': 'No wake word engine', + }) +# --- +# name: test_audio_pipeline_no_wake_word_entity + dict({ + 'code': 'wake-provider-missing', + 'message': 'No wake-word-detection provider for: wake_word.bad-entity-id', + }) +# --- +# name: test_audio_pipeline_with_wake_word + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 30, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word.1 + dict({ + 'engine': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word.2 + dict({ + 'wake_word_output': dict({ + 'queued_audio': None, + 'timestamp': 1000, + 'ww_id': 'test_ww', + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word.3 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word.4 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word.5 + dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }) +# --- +# name: test_audio_pipeline_with_wake_word.6 + dict({ + 'intent_output': 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_audio_pipeline_with_wake_word.7 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }) +# --- +# name: test_audio_pipeline_with_wake_word.8 + dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 30, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.1 + dict({ + 'engine': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.2 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'ww_id': 'test_ww', + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.3 + dict({ + 'engine': 'test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.4 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.5 + dict({ + 'conversation_id': None, + 'device_id': None, + 'engine': 'homeassistant', + 'intent_input': 'test transcript', + 'language': 'en', + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.6 + dict({ + 'intent_output': 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_audio_pipeline_with_wake_word_no_timeout.7 + dict({ + 'engine': 'test', + 'language': 'en-US', + 'tts_input': "Sorry, I couldn't understand that", + 'voice': 'james_earl_jones', + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.8 + dict({ + 'tts_output': dict({ + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_no_timeout.9 + None +# --- +# name: test_audio_pipeline_with_wake_word_timeout + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 30, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_timeout.1 + dict({ + 'engine': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline_with_wake_word_timeout.2 + dict({ + 'code': 'wake-word-timeout', + 'message': 'Wake word was not detected', + }) +# --- +# name: test_audio_pipeline_with_wake_word_timeout.3 + None +# --- # name: test_intent_failed dict({ 'language': 'en', @@ -174,6 +423,9 @@ 'language': 'en', }) # --- +# name: test_intent_failed.2 + None +# --- # name: test_intent_timeout dict({ 'language': 'en', @@ -194,6 +446,9 @@ }) # --- # name: test_intent_timeout.2 + None +# --- +# name: test_intent_timeout.3 dict({ 'code': 'timeout', 'message': 'Timeout running pipeline', @@ -245,6 +500,9 @@ }), }) # --- +# name: test_stt_stream_failed.2 + None +# --- # name: test_text_only_pipeline dict({ 'language': 'en', @@ -286,6 +544,9 @@ }), }) # --- +# name: test_text_only_pipeline.3 + None +# --- # name: test_text_pipeline_timeout dict({ 'code': 'timeout', @@ -310,3 +571,6 @@ 'voice': 'james_earl_jones', }) # --- +# name: test_tts_failed.2 + None +# --- diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 392363fc0cc..8687e2ad40c 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,17 +1,24 @@ """Test Voice Assistant init.""" from dataclasses import asdict -from unittest.mock import ANY +import itertools as it +from pathlib import Path +import tempfile +from unittest.mock import ANY, patch +import wave import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import assist_pipeline, stt from homeassistant.core import Context, HomeAssistant +from homeassistant.setup import async_setup_component -from .conftest import MockSttProvider, MockSttProviderEntity +from .conftest import MockSttProvider, MockSttProviderEntity, MockWakeWordEntity from tests.typing import WebSocketGenerator +BYTES_ONE_SECOND = 16000 * 2 + def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: """Process events to remove dynamic values.""" @@ -37,7 +44,7 @@ async def test_pipeline_from_audio_stream_auto( In this test, no pipeline is specified. """ - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -46,9 +53,9 @@ async def test_pipeline_from_audio_stream_auto( await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -56,7 +63,7 @@ async def test_pipeline_from_audio_stream_auto( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), ) assert process_events(events) == snapshot @@ -76,7 +83,7 @@ async def test_pipeline_from_audio_stream_legacy( """ client = await hass_ws_client(hass) - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -105,9 +112,9 @@ async def test_pipeline_from_audio_stream_legacy( # Use the created pipeline await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -115,7 +122,7 @@ async def test_pipeline_from_audio_stream_legacy( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id=pipeline_id, ) @@ -136,7 +143,7 @@ async def test_pipeline_from_audio_stream_entity( """ client = await hass_ws_client(hass) - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -165,9 +172,9 @@ async def test_pipeline_from_audio_stream_entity( # Use the created pipeline await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -175,7 +182,7 @@ async def test_pipeline_from_audio_stream_entity( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id=pipeline_id, ) @@ -196,7 +203,7 @@ async def test_pipeline_from_audio_stream_no_stt( """ client = await hass_ws_client(hass) - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -226,9 +233,9 @@ async def test_pipeline_from_audio_stream_no_stt( with pytest.raises(assist_pipeline.pipeline.PipelineRunValidationError): await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -236,7 +243,7 @@ async def test_pipeline_from_audio_stream_no_stt( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id=pipeline_id, ) @@ -254,7 +261,7 @@ async def test_pipeline_from_audio_stream_unknown_pipeline( In this test, the pipeline does not exist. """ - events = [] + events: list[assist_pipeline.PipelineEvent] = [] async def audio_data(): yield b"part1" @@ -265,9 +272,9 @@ async def test_pipeline_from_audio_stream_unknown_pipeline( with pytest.raises(assist_pipeline.PipelineNotFound): await assist_pipeline.async_pipeline_from_audio_stream( hass, - Context(), - events.append, - stt.SpeechMetadata( + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( language="en-UK", format=stt.AudioFormats.WAV, codec=stt.AudioCodecs.PCM, @@ -275,8 +282,258 @@ async def test_pipeline_from_audio_stream_unknown_pipeline( sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - audio_data(), + stt_stream=audio_data(), pipeline_id="blah", ) assert not events + + +async def test_pipeline_from_audio_stream_wake_word( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream with wake word.""" + + events: list[assist_pipeline.PipelineEvent] = [] + + # [0, 1, ...] + wake_chunk_1 = bytes(it.islice(it.cycle(range(256)), BYTES_ONE_SECOND)) + + # [0, 2, ...] + wake_chunk_2 = bytes(it.islice(it.cycle(range(0, 256, 2)), BYTES_ONE_SECOND)) + + async def audio_data(): + yield wake_chunk_1 # 1 second + yield wake_chunk_2 # 1 second + yield b"wake word!" + yield b"part1" + yield b"part2" + yield b"end" + yield b"" + + def continue_stt(self, chunk): + # Ensure stt_vad_start event is triggered + self.in_command = True + + # Stop on fake end chunk to trigger stt_vad_end + return chunk != b"end" + + with patch( + "homeassistant.components.assist_pipeline.pipeline.VoiceCommandSegmenter.process", + continue_stt, + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + wake_word_settings=assist_pipeline.WakeWordSettings( + audio_seconds_to_buffer=1.5 + ), + ) + + assert process_events(events) == snapshot + + # 1. Half of wake_chunk_1 + all wake_chunk_2 + # 2. queued audio (from mock wake word entity) + # 3. part1 + # 4. part2 + assert len(mock_stt_provider.received) == 4 + + first_chunk = mock_stt_provider.received[0] + assert first_chunk == wake_chunk_1[len(wake_chunk_1) // 2 :] + wake_chunk_2 + + assert mock_stt_provider.received[1:] == [b"queued audio", b"part1", b"part2"] + + +async def test_pipeline_save_audio( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test saving audio during a pipeline run.""" + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + "assist_pipeline", + {"assist_pipeline": {"debug_recording_dir": temp_dir_str}}, + ) + + pipeline = assist_pipeline.async_get_pipeline(hass) + events: list[assist_pipeline.PipelineEvent] = [] + + # Pad out to an even number of bytes since these "samples" will be saved + # as 16-bit values. + async def audio_data(): + yield b"wake word_" + # queued audio + yield b"part1_" + yield b"part2_" + yield b"" + + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=events.append, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + pipeline_id=pipeline.id, + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.STT, + ) + + pipeline_dirs = list(temp_dir.iterdir()) + + # Only one pipeline run + # // + assert len(pipeline_dirs) == 1 + assert pipeline_dirs[0].is_dir() + assert pipeline_dirs[0].name == pipeline.name + + # Wake and stt files + run_dirs = list(pipeline_dirs[0].iterdir()) + assert run_dirs[0].is_dir() + run_files = list(run_dirs[0].iterdir()) + + assert len(run_files) == 2 + wake_file = run_files[0] if "wake" in run_files[0].name else run_files[1] + stt_file = run_files[0] if "stt" in run_files[0].name else run_files[1] + assert wake_file != stt_file + + # Verify wake file + with wave.open(str(wake_file), "rb") as wake_wav: + wake_data = wake_wav.readframes(wake_wav.getnframes()) + assert wake_data == b"wake word_" + + # Verify stt file + with wave.open(str(stt_file), "rb") as stt_wav: + stt_data = stt_wav.readframes(stt_wav.getnframes()) + assert stt_data == b"queued audiopart1_part2_" + + +async def test_pipeline_saved_audio_with_device_id( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that saved audio directory uses device id.""" + device_id = "test-device-id" + + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + "assist_pipeline", + {"assist_pipeline": {"debug_recording_dir": temp_dir_str}}, + ) + + def event_callback(event: assist_pipeline.PipelineEvent): + if event.type == "run-end": + # Verify that saved audio directory is named after device id + device_dirs = list(temp_dir.iterdir()) + assert device_dirs[0].name == device_id + + async def audio_data(): + yield b"not used" + + # Force a timeout during wake word detection + with patch.object( + mock_wake_word_provider_entity, + "async_process_audio_stream", + side_effect=assist_pipeline.error.WakeWordTimeoutError( + code="timeout", message="timeout" + ), + ): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=event_callback, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.STT, + device_id=device_id, + ) + + +async def test_pipeline_saved_audio_write_error( + hass: HomeAssistant, + mock_stt_provider: MockSttProvider, + mock_wake_word_provider_entity: MockWakeWordEntity, + init_supporting_components, + snapshot: SnapshotAssertion, +) -> None: + """Test that saved audio thread closes WAV file even if there's a write error.""" + with tempfile.TemporaryDirectory() as temp_dir_str: + # Enable audio recording to temporary directory + temp_dir = Path(temp_dir_str) + assert await async_setup_component( + hass, + "assist_pipeline", + {"assist_pipeline": {"debug_recording_dir": temp_dir_str}}, + ) + + def event_callback(event: assist_pipeline.PipelineEvent): + if event.type == "run-end": + # Verify WAV file exists, but contains no data + pipeline_dirs = list(temp_dir.iterdir()) + run_dirs = list(pipeline_dirs[0].iterdir()) + wav_path = next(run_dirs[0].iterdir()) + with wave.open(str(wav_path), "rb") as wav_file: + assert wav_file.getnframes() == 0 + + async def audio_data(): + yield b"not used" + + # Force a timeout during wake word detection + with patch("wave.Wave_write.writeframes", raises=RuntimeError()): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + context=Context(), + event_callback=event_callback, + stt_metadata=stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_data(), + start_stage=assist_pipeline.PipelineStage.WAKE_WORD, + end_stage=assist_pipeline.PipelineStage.STT, + ) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index f6a62a630d2..32468e3af91 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -31,7 +31,7 @@ async def load_homeassistant(hass) -> None: assert await async_setup_component(hass, "homeassistant", {}) -async def test_load_datasets(hass: HomeAssistant, init_components) -> None: +async def test_load_pipelines(hass: HomeAssistant, init_components) -> None: """Make sure that we can load/save data correctly.""" pipelines = [ @@ -92,10 +92,10 @@ async def test_load_datasets(hass: HomeAssistant, init_components) -> None: assert store1.async_get_preferred_item() == store2.async_get_preferred_item() -async def test_loading_datasets_from_storage( +async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: - """Test loading stored datasets on start.""" + """Test loading stored pipelines on start.""" hass_storage[STORAGE_KEY] = { "version": 1, "minor_version": 1, diff --git a/tests/components/assist_pipeline/test_ring_buffer.py b/tests/components/assist_pipeline/test_ring_buffer.py new file mode 100644 index 00000000000..22185c3ad5b --- /dev/null +++ b/tests/components/assist_pipeline/test_ring_buffer.py @@ -0,0 +1,38 @@ +"""Tests for audio ring buffer.""" +from homeassistant.components.assist_pipeline.ring_buffer import RingBuffer + + +def test_ring_buffer_empty() -> None: + """Test empty ring buffer.""" + rb = RingBuffer(10) + assert rb.maxlen == 10 + assert rb.pos == 0 + assert rb.getvalue() == b"" + + +def test_ring_buffer_put_1() -> None: + """Test putting some data smaller than the maximum length.""" + rb = RingBuffer(10) + rb.put(bytes([1, 2, 3, 4, 5])) + assert len(rb) == 5 + assert rb.pos == 5 + assert rb.getvalue() == bytes([1, 2, 3, 4, 5]) + + +def test_ring_buffer_put_2() -> None: + """Test putting some data past the end of the buffer.""" + rb = RingBuffer(10) + rb.put(bytes([1, 2, 3, 4, 5])) + rb.put(bytes([6, 7, 8, 9, 10, 11, 12])) + assert len(rb) == 10 + assert rb.pos == 2 + assert rb.getvalue() == bytes([3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + + +def test_ring_buffer_put_too_large() -> None: + """Test putting data too large for the buffer.""" + rb = RingBuffer(10) + rb.put(bytes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])) + assert len(rb) == 10 + assert rb.pos == 2 + assert rb.getvalue() == bytes([3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 29e6f9a8f31..1419eb58750 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -17,7 +17,7 @@ from homeassistant.components.assist_pipeline.vad import VadSensitivity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import MockConfigEntry, MockPlatform, mock_entity_platform @@ -26,7 +26,6 @@ from tests.common import MockConfigEntry, MockPlatform, mock_entity_platform class SelectPlatform(MockPlatform): """Fake select platform.""" - # pylint: disable=method-hidden async def async_setup_entry( self, hass: HomeAssistant, diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index 3a5c763ee5c..4dc8c8f6197 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -1,7 +1,12 @@ """Tests for webrtcvad voice command segmenter.""" +import itertools as it from unittest.mock import patch -from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter +from homeassistant.components.assist_pipeline.vad import ( + AudioBuffer, + VoiceCommandSegmenter, + chunk_samples, +) _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -36,3 +41,87 @@ def test_speech() -> None: # silence # False return value indicates voice command is finished assert not segmenter.process(bytes(_ONE_SECOND)) + + +def test_audio_buffer() -> None: + """Test audio buffer wrapping.""" + + def is_speech(self, chunk, sample_rate): + """Disable VAD.""" + return False + + with patch( + "webrtcvad.Vad.is_speech", + new=is_speech, + ): + segmenter = VoiceCommandSegmenter() + bytes_per_chunk = segmenter.vad_samples_per_chunk * 2 + + with patch.object( + segmenter, "_process_chunk", return_value=True + ) as mock_process: + # Partially fill audio buffer + half_chunk = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk // 2)) + segmenter.process(half_chunk) + + assert not mock_process.called + assert segmenter.audio_buffer == half_chunk + + # Fill and wrap with 1/4 chunk left over + three_quarters_chunk = bytes( + it.islice(it.cycle(range(256)), int(0.75 * bytes_per_chunk)) + ) + segmenter.process(three_quarters_chunk) + + assert mock_process.call_count == 1 + assert ( + segmenter.audio_buffer + == three_quarters_chunk[ + len(three_quarters_chunk) - (bytes_per_chunk // 4) : + ] + ) + assert ( + mock_process.call_args[0][0] + == half_chunk + three_quarters_chunk[: bytes_per_chunk // 2] + ) + + # Run 2 chunks through + segmenter.reset() + assert len(segmenter.audio_buffer) == 0 + + mock_process.reset_mock() + two_chunks = bytes(it.islice(it.cycle(range(256)), bytes_per_chunk * 2)) + segmenter.process(two_chunks) + + assert mock_process.call_count == 2 + assert len(segmenter.audio_buffer) == 0 + assert mock_process.call_args_list[0][0][0] == two_chunks[:bytes_per_chunk] + assert mock_process.call_args_list[1][0][0] == two_chunks[bytes_per_chunk:] + + +def test_partial_chunk() -> None: + """Test that chunk_samples returns when given a partial chunk.""" + bytes_per_chunk = 5 + samples = bytes([1, 2, 3]) + leftover_chunk_buffer = AudioBuffer(bytes_per_chunk) + chunks = list(chunk_samples(samples, bytes_per_chunk, leftover_chunk_buffer)) + + assert len(chunks) == 0 + assert leftover_chunk_buffer.bytes() == samples + + +def test_chunk_samples_leftover() -> None: + """Test that chunk_samples property keeps left over bytes across calls.""" + bytes_per_chunk = 5 + samples = bytes([1, 2, 3, 4, 5, 6]) + leftover_chunk_buffer = AudioBuffer(bytes_per_chunk) + chunks = list(chunk_samples(samples, bytes_per_chunk, leftover_chunk_buffer)) + + assert len(chunks) == 1 + assert leftover_chunk_buffer.bytes() == bytes([6]) + + # Add some more to the chunk + chunks = list(chunk_samples(samples, bytes_per_chunk, leftover_chunk_buffer)) + + assert len(chunks) == 1 + assert leftover_chunk_buffer.bytes() == bytes([5, 6]) diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 4ebf0a1fb98..ca631be4549 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -58,7 +58,7 @@ async def test_text_only_pipeline( # run end msg = await client.receive_json() assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] is None + assert msg["event"]["data"] == snapshot events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] @@ -148,7 +148,7 @@ async def test_audio_pipeline( # run end msg = await client.receive_json() assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] is None + assert msg["event"]["data"] == snapshot events.append(msg["event"]) pipeline_data: PipelineData = hass.data[DOMAIN] @@ -167,6 +167,230 @@ async def test_audio_pipeline( assert msg["result"] == {"events": events} +async def test_audio_pipeline_with_wake_word_timeout( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test timeout from a pipeline run with audio input/output + wake word.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "timeout": 1, + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"], msg + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # wake_word + msg = await client.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # 2 seconds of silence + await client.send_bytes(bytes([1]) + bytes(16000 * 2 * 2)) + + # Time out error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + +async def test_audio_pipeline_with_wake_word_no_timeout( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with audio input/output + wake word with no timeout.""" + events = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "timeout": 0, + }, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"], msg + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # wake_word + msg = await client.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # "audio" + await client.send_bytes(bytes([1]) + b"wake word") + + msg = await client.receive_json() + assert msg["event"]["type"] == "wake_word-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # End of audio stream (handler id + empty payload) + await client.send_bytes(bytes([1])) + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # text-to-speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + + pipeline_data: PipelineData = hass.data[DOMAIN] + pipeline_id = list(pipeline_data.pipeline_runs)[0] + pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] + + await client.send_json_auto_id( + { + "type": "assist_pipeline/pipeline_debug/get", + "pipeline_id": pipeline_id, + "pipeline_run_id": pipeline_run_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"events": events} + + +async def test_audio_pipeline_no_wake_word_engine( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test timeout from a pipeline run with audio input/output + wake word.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.wake_word.async_default_engine", return_value=None + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + }, + } + ) + + # error + msg = await client.receive_json() + assert not msg["success"] + assert "error" in msg + assert msg["error"] == snapshot + + +async def test_audio_pipeline_no_wake_word_entity( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test timeout from a pipeline run with audio input/output + wake word.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.wake_word.async_default_engine", + return_value="wake_word.bad-entity-id", + ), patch( + "homeassistant.components.wake_word.async_get_wake_word_detection_entity", + return_value=None, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + }, + } + ) + + # error + msg = await client.receive_json() + assert not msg["success"] + assert "error" in msg + assert msg["error"] == snapshot + + async def test_intent_timeout( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -211,6 +435,12 @@ async def test_intent_timeout( assert msg["event"]["data"] == snapshot events.append(msg["event"]) + # run-end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + # timeout error msg = await client.receive_json() assert msg["event"]["type"] == "error" @@ -332,6 +562,12 @@ async def test_intent_failed( assert msg["event"]["data"]["code"] == "intent-failed" events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_id = list(pipeline_data.pipeline_runs)[0] pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] @@ -512,6 +748,12 @@ async def test_stt_stream_failed( assert msg["event"]["data"]["code"] == "stt-stream-failed" events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_id = list(pipeline_data.pipeline_runs)[0] pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] @@ -574,6 +816,12 @@ async def test_tts_failed( assert msg["event"]["data"]["code"] == "tts-failed" events.append(msg["event"]) + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == snapshot + events.append(msg["event"]) + pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_id = list(pipeline_data.pipeline_runs)[0] pipeline_run_id = list(pipeline_data.pipeline_runs[pipeline_id])[0] @@ -1242,7 +1490,7 @@ async def test_audio_pipeline_debug( # run end msg = await client.receive_json() assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] is None + assert msg["event"]["data"] == snapshot events.append(msg["event"]) # Get the id of the pipeline diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 2d7bda491a8..52525390666 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -95,6 +95,7 @@ def create_device_registry_devices_fixture(hass: HomeAssistant): """Create device registry devices so the device tracker entities are enabled when added.""" dev_reg = dr.async_get(hass) config_entry = MockConfigEntry(domain="something_else") + config_entry.add_to_hass(hass) for idx, device in enumerate( ( diff --git a/tests/components/august/conftest.py b/tests/components/august/conftest.py new file mode 100644 index 00000000000..1cb52966fea --- /dev/null +++ b/tests/components/august/conftest.py @@ -0,0 +1,13 @@ +"""August tests conftest.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(name="mock_discovery", autouse=True) +def mock_discovery_fixture(): + """Mock discovery to avoid loading the whole bluetooth stack.""" + with patch( + "homeassistant.components.august.discovery_flow.async_create_flow" + ) as mock_discovery: + yield mock_discovery diff --git a/tests/components/august/fixtures/lock_with_doorbell.online.json b/tests/components/august/fixtures/lock_with_doorbell.online.json new file mode 100644 index 00000000000..bb2367d1111 --- /dev/null +++ b/tests/components/august/fixtures/lock_with_doorbell.online.json @@ -0,0 +1,100 @@ +{ + "LockName": "Front Door Lock", + "Type": 7, + "Created": "2017-12-10T03:12:09.210Z", + "Updated": "2017-12-10T03:12:09.210Z", + "LockID": "A6697750D607098BAE8D6BAA11EF8063", + "HouseID": "000000000000", + "HouseName": "My House", + "Calibrated": false, + "skuNumber": "AUG-SL02-M02-S02", + "timeZone": "America/Vancouver", + "battery": 0.88, + "SerialNumber": "X2FSW05DGA", + "LockStatus": { + "status": "locked", + "doorState": "closed", + "dateTime": "2017-12-10T04:48:30.272Z", + "isLockStatusChanged": true, + "valid": true + }, + "currentFirmwareVersion": "109717e9-3.0.44-3.0.30", + "homeKitEnabled": false, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "aaacab87f7efxa0015884999", + "mfgBridgeID": "AAGPP102XX", + "deviceModel": "august-doorbell", + "firmwareVersion": "2.3.0-RC153+201711151527", + "operative": true + }, + "keypad": { + "_id": "5bc65c24e6ef2a263e1450a8", + "serialNumber": "K1GXB0054Z", + "lockID": "92412D1B44004595B5DEB134E151A8D3", + "currentFirmwareVersion": "2.27.0", + "battery": {}, + "batteryLevel": "Medium", + "batteryRaw": 170 + }, + "OfflineKeys": { + "created": [], + "loaded": [ + { + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "slot": 1, + "key": "kkk01d4300c1dcxxx1c330f794941111", + "created": "2017-12-10T03:12:09.215Z", + "loaded": "2017-12-10T03:12:54.391Z" + } + ], + "deleted": [], + "loadedhk": [ + { + "key": "kkk01d4300c1dcxxx1c330f794941222", + "slot": 256, + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "created": "2017-12-10T03:12:09.218Z", + "loaded": "2017-12-10T03:12:55.563Z" + } + ] + }, + "parametersToSet": {}, + "users": { + "cccca94e-373e-aaaa-bbbb-333396827777": { + "UserType": "superuser", + "FirstName": "Foo", + "LastName": "Bar", + "identifiers": ["email:foo@bar.com", "phone:+177777777777"], + "imageInfo": { + "original": { + "width": 948, + "height": 949, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + }, + "thumbnail": { + "width": 128, + "height": 128, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + } + } + } + }, + "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333", + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + } +} diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index d5517f64249..910c1d29ed6 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -162,24 +162,23 @@ async def _create_august_api_with_devices( # noqa: C901 _mock_door_operation_activity(lock, "dooropen", 0), ] - if "get_lock_detail" not in api_call_side_effects: - api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect - if "get_doorbell_detail" not in api_call_side_effects: - api_call_side_effects["get_doorbell_detail"] = get_doorbell_detail_side_effect - if "get_operable_locks" not in api_call_side_effects: - api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect - if "get_doorbells" not in api_call_side_effects: - api_call_side_effects["get_doorbells"] = get_doorbells_side_effect - if "get_house_activities" not in api_call_side_effects: - api_call_side_effects["get_house_activities"] = get_house_activities_side_effect - if "lock_return_activities" not in api_call_side_effects: - api_call_side_effects[ - "lock_return_activities" - ] = lock_return_activities_side_effect - if "unlock_return_activities" not in api_call_side_effects: - api_call_side_effects[ - "unlock_return_activities" - ] = unlock_return_activities_side_effect + api_call_side_effects.setdefault("get_lock_detail", get_lock_detail_side_effect) + api_call_side_effects.setdefault( + "get_doorbell_detail", get_doorbell_detail_side_effect + ) + api_call_side_effects.setdefault( + "get_operable_locks", get_operable_locks_side_effect + ) + api_call_side_effects.setdefault("get_doorbells", get_doorbells_side_effect) + api_call_side_effects.setdefault( + "get_house_activities", get_house_activities_side_effect + ) + api_call_side_effects.setdefault( + "lock_return_activities", lock_return_activities_side_effect + ) + api_call_side_effects.setdefault( + "unlock_return_activities", unlock_return_activities_side_effect + ) api_instance, entry = await _mock_setup_august_with_api_side_effects( hass, api_call_side_effects, pubnub diff --git a/tests/components/august/snapshots/test_diagnostics.ambr b/tests/components/august/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b394255c555 --- /dev/null +++ b/tests/components/august/snapshots/test_diagnostics.ambr @@ -0,0 +1,125 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'brand': 'august', + 'doorbells': dict({ + 'K98GiDT45GUL': dict({ + 'HouseID': '**REDACTED**', + 'LockID': 'BBBB1F5F11114C24CCCC97571DD6AAAA', + 'appID': 'august-iphone', + 'caps': list([ + 'reconnect', + ]), + 'createdAt': '2016-11-26T22:27:11.176Z', + 'doorbellID': 'K98GiDT45GUL', + 'doorbellServerURL': 'https://doorbells.august.com', + 'dvrSubscriptionSetupDone': True, + 'firmwareVersion': '2.3.0-RC153+201711151527', + 'installDate': '2016-11-26T22:27:11.176Z', + 'installUserID': '**REDACTED**', + 'name': 'Front Door', + 'pubsubChannel': '**REDACTED**', + 'recentImage': '**REDACTED**', + 'serialNumber': 'tBXZR0Z35E', + 'settings': dict({ + 'ABREnabled': True, + 'IREnabled': True, + 'IVAEnabled': False, + 'JPGQuality': 70, + 'batteryLowThreshold': 3.1, + 'batteryRun': False, + 'batteryUseThreshold': 3.4, + 'bitrateCeiling': 512000, + 'buttonpush_notifications': True, + 'debug': False, + 'directLink': True, + 'initialBitrate': 384000, + 'irConfiguration': 8448272, + 'keepEncoderRunning': True, + 'micVolume': 100, + 'minACNoScaling': 40, + 'motion_notifications': True, + 'notify_when_offline': True, + 'overlayEnabled': True, + 'ringSoundEnabled': True, + 'speakerVolume': 92, + 'turnOffCamera': False, + 'videoResolution': '640x480', + }), + 'status': 'doorbell_call_status_online', + 'status_timestamp': 1512811834532, + 'telemetry': dict({ + 'BSSID': '88:ee:00:dd:aa:11', + 'SSID': 'foo_ssid', + 'ac_in': 23.856874, + 'battery': 4.061763, + 'battery_soc': 96, + 'battery_soh': 95, + 'date': '2017-12-10 08:05:12', + 'doorbell_low_battery': False, + 'ip_addr': '10.0.1.11', + 'link_quality': 54, + 'load_average': '0.50 0.47 0.35 1/154 9345', + 'signal_level': -56, + 'steady_ac_in': 22.196405, + 'temperature': 28.25, + 'updated_at': '2017-12-10T08:05:13.650Z', + 'uptime': '16168.75 13830.49', + 'wifi_freq': 5745, + }), + 'updatedAt': '2017-12-10T08:05:13.650Z', + }), + }), + 'locks': dict({ + 'online_with_doorsense': dict({ + 'Bridge': dict({ + '_id': 'bridgeid', + 'deviceModel': 'august-connect', + 'firmwareVersion': '2.2.1', + 'hyperBridge': True, + 'mfgBridgeID': 'C5WY200WSH', + 'operative': True, + 'status': dict({ + 'current': 'online', + 'lastOffline': '2000-00-00T00:00:00.447Z', + 'lastOnline': '2000-00-00T00:00:00.447Z', + 'updated': '2000-00-00T00:00:00.447Z', + }), + }), + 'Calibrated': False, + 'Created': '2000-00-00T00:00:00.447Z', + 'HouseID': '**REDACTED**', + 'HouseName': 'Test', + 'LockID': 'online_with_doorsense', + 'LockName': 'Online door with doorsense', + 'LockStatus': dict({ + 'dateTime': '2017-12-10T04:48:30.272Z', + 'doorState': 'open', + 'isLockStatusChanged': False, + 'status': 'locked', + 'valid': True, + }), + 'SerialNumber': 'XY', + 'Type': 1001, + 'Updated': '2000-00-00T00:00:00.447Z', + 'battery': 0.922, + 'currentFirmwareVersion': 'undefined-4.3.0-1.8.14', + 'homeKitEnabled': True, + 'hostLockInfo': dict({ + 'manufacturer': 'yale', + 'productID': 1536, + 'productTypeID': 32770, + 'serialNumber': 'ABC', + }), + 'isGalileo': False, + 'macAddress': '12:22', + 'pins': '**REDACTED**', + 'pubsubChannel': '**REDACTED**', + 'skuNumber': 'AUG-MD01', + 'supportsEntryCodes': True, + 'timeZone': 'Pacific/Hawaii', + 'zWaveEnabled': False, + }), + }), + }) +# --- diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index f66ba73cebc..50cac4445ab 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -41,7 +41,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [lock_one]) binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -50,7 +50,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -58,7 +58,7 @@ async def test_doorsense(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_OFF @@ -74,7 +74,7 @@ async def test_lock_bridge_offline(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [lock_one], activities=activities) binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_UNAVAILABLE @@ -93,11 +93,11 @@ async def test_create_doorbell(hass: HomeAssistant) -> None: ) assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_online" + "binary_sensor.k98gidt45gul_name_connectivity" ) assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF binary_sensor_k98gidt45gul_name_motion = hass.states.get( @@ -120,10 +120,12 @@ async def test_create_doorbell_offline(hass: HomeAssistant) -> None: ) assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE binary_sensor_tmt100_name_online = hass.states.get( - "binary_sensor.tmt100_name_online" + "binary_sensor.tmt100_name_connectivity" ) assert binary_sensor_tmt100_name_online.state == STATE_OFF - binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding") + binary_sensor_tmt100_name_ding = hass.states.get( + "binary_sensor.tmt100_name_occupancy" + ) assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE @@ -140,11 +142,11 @@ async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: ) assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_online" + "binary_sensor.k98gidt45gul_name_connectivity" ) assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) @@ -174,7 +176,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: ) assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF @@ -242,7 +244,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF @@ -273,7 +275,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_ON new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) @@ -286,7 +288,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_ding" + "binary_sensor.k98gidt45gul_name_occupancy" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF @@ -317,7 +319,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ) binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -332,7 +334,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_OFF @@ -346,14 +348,14 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -361,7 +363,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -369,7 +371,7 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON @@ -383,16 +385,27 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() + + +async def test_create_lock_with_doorbell(hass: HomeAssistant) -> None: + """Test creation of a lock with a doorbell.""" + lock_one = await _mock_lock_from_fixture(hass, "lock_with_doorbell.online.json") + await _create_august_with_devices(hass, [lock_one]) + + ding_sensor = hass.states.get( + "binary_sensor.a6697750d607098bae8d6baa11ef8063_name_occupancy" + ) + assert ding_sensor.state == STATE_OFF diff --git a/tests/components/august/test_diagnostics.py b/tests/components/august/test_diagnostics.py index c15ccfd0119..72008f02d03 100644 --- a/tests/components/august/test_diagnostics.py +++ b/tests/components/august/test_diagnostics.py @@ -1,4 +1,6 @@ """Test august diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from .mocks import ( @@ -12,7 +14,9 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" lock_one = await _mock_lock_from_fixture( @@ -23,123 +27,4 @@ async def test_diagnostics( entry, _ = await _create_august_api_with_devices(hass, [lock_one, doorbell_one]) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "doorbells": { - "K98GiDT45GUL": { - "HouseID": "**REDACTED**", - "LockID": "BBBB1F5F11114C24CCCC97571DD6AAAA", - "appID": "august-iphone", - "caps": ["reconnect"], - "createdAt": "2016-11-26T22:27:11.176Z", - "doorbellID": "K98GiDT45GUL", - "doorbellServerURL": "https://doorbells.august.com", - "dvrSubscriptionSetupDone": True, - "firmwareVersion": "2.3.0-RC153+201711151527", - "installDate": "2016-11-26T22:27:11.176Z", - "installUserID": "**REDACTED**", - "name": "Front Door", - "pubsubChannel": "**REDACTED**", - "recentImage": "**REDACTED**", - "serialNumber": "tBXZR0Z35E", - "settings": { - "ABREnabled": True, - "IREnabled": True, - "IVAEnabled": False, - "JPGQuality": 70, - "batteryLowThreshold": 3.1, - "batteryRun": False, - "batteryUseThreshold": 3.4, - "bitrateCeiling": 512000, - "buttonpush_notifications": True, - "debug": False, - "directLink": True, - "initialBitrate": 384000, - "irConfiguration": 8448272, - "keepEncoderRunning": True, - "micVolume": 100, - "minACNoScaling": 40, - "motion_notifications": True, - "notify_when_offline": True, - "overlayEnabled": True, - "ringSoundEnabled": True, - "speakerVolume": 92, - "turnOffCamera": False, - "videoResolution": "640x480", - }, - "status": "doorbell_call_status_online", - "status_timestamp": 1512811834532, - "telemetry": { - "BSSID": "88:ee:00:dd:aa:11", - "SSID": "foo_ssid", - "ac_in": 23.856874, - "battery": 4.061763, - "battery_soc": 96, - "battery_soh": 95, - "date": "2017-12-10 08:05:12", - "doorbell_low_battery": False, - "ip_addr": "10.0.1.11", - "link_quality": 54, - "load_average": "0.50 0.47 0.35 1/154 9345", - "signal_level": -56, - "steady_ac_in": 22.196405, - "temperature": 28.25, - "updated_at": "2017-12-10T08:05:13.650Z", - "uptime": "16168.75 13830.49", - "wifi_freq": 5745, - }, - "updatedAt": "2017-12-10T08:05:13.650Z", - } - }, - "locks": { - "online_with_doorsense": { - "Bridge": { - "_id": "bridgeid", - "deviceModel": "august-connect", - "firmwareVersion": "2.2.1", - "hyperBridge": True, - "mfgBridgeID": "C5WY200WSH", - "operative": True, - "status": { - "current": "online", - "lastOffline": "2000-00-00T00:00:00.447Z", - "lastOnline": "2000-00-00T00:00:00.447Z", - "updated": "2000-00-00T00:00:00.447Z", - }, - }, - "Calibrated": False, - "Created": "2000-00-00T00:00:00.447Z", - "HouseID": "**REDACTED**", - "HouseName": "Test", - "LockID": "online_with_doorsense", - "LockName": "Online door with doorsense", - "LockStatus": { - "dateTime": "2017-12-10T04:48:30.272Z", - "doorState": "open", - "isLockStatusChanged": False, - "status": "locked", - "valid": True, - }, - "SerialNumber": "XY", - "Type": 1001, - "Updated": "2000-00-00T00:00:00.447Z", - "battery": 0.922, - "currentFirmwareVersion": "undefined-4.3.0-1.8.14", - "homeKitEnabled": True, - "hostLockInfo": { - "manufacturer": "yale", - "productID": 1536, - "productTypeID": 32770, - "serialNumber": "ABC", - }, - "isGalileo": False, - "macAddress": "12:22", - "pins": "**REDACTED**", - "pubsubChannel": "**REDACTED**", - "skuNumber": "AUG-MD01", - "supportsEntryCodes": True, - "timeZone": "Pacific/Hawaii", - "zWaveEnabled": False, - } - }, - "brand": "august", - } + assert diag == snapshot diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 23ea12a9f82..36a7f73f8a8 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -1,6 +1,6 @@ """The tests for the august platform.""" import asyncio -from unittest.mock import patch +from unittest.mock import Mock, patch from aiohttp import ClientResponseError from yalexs.authenticator_common import AuthenticationState @@ -186,11 +186,11 @@ async def test_lock_has_doorsense(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [doorsenselock, nodoorsenselock]) binary_sensor_online_with_doorsense_name_open = hass.states.get( - "binary_sensor.online_with_doorsense_name_open" + "binary_sensor.online_with_doorsense_name_door" ) assert binary_sensor_online_with_doorsense_name_open.state == STATE_ON binary_sensor_missing_doorsense_id_name_open = hass.states.get( - "binary_sensor.missing_doorsense_id_name_open" + "binary_sensor.missing_with_doorsense_name_door" ) assert binary_sensor_missing_doorsense_id_name_open is None @@ -361,19 +361,18 @@ async def test_load_unload(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_load_triggers_ble_discovery(hass: HomeAssistant) -> None: +async def test_load_triggers_ble_discovery( + hass: HomeAssistant, mock_discovery: Mock +) -> None: """Test that loading a lock that supports offline ble operation passes the keys to yalexe_ble.""" august_lock_with_key = await _mock_lock_with_offline_key(hass) august_lock_without_key = await _mock_operative_august_lock_detail(hass) - with patch( - "homeassistant.components.august.discovery_flow.async_create_flow" - ) as mock_discovery: - config_entry = await _create_august_with_devices( - hass, [august_lock_with_key, august_lock_without_key] - ) - await hass.async_block_till_done() + config_entry = await _create_august_with_devices( + hass, [august_lock_with_key, august_lock_without_key] + ) + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED assert len(mock_discovery.mock_calls) == 1 diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index 3eb1972011c..dc32212ee87 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -3,8 +3,11 @@ from unittest.mock import patch from aiohttp import ClientConnectionError from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType +import pydantic +import pytest from homeassistant import data_entry_flow +from homeassistant.components.aussie_broadband import validate_service_type from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -19,6 +22,19 @@ async def test_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED +async def test_validate_service_type() -> None: + """Testing the validation function.""" + test_service = {"type": "Hardware", "name": "test service"} + validate_service_type(test_service) + + with pytest.raises(ValueError): + test_service = {"name": "test service"} + validate_service_type(test_service) + with pytest.raises(UnrecognisedServiceType): + test_service = {"type": "FunkyBob", "name": "test service"} + validate_service_type(test_service) + + async def test_auth_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" with patch( @@ -39,3 +55,9 @@ async def test_service_failure(hass: HomeAssistant) -> None: """Test init with a invalid service.""" entry = await setup_platform(hass, usage_effect=UnrecognisedServiceType()) assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_not_pydantic2() -> None: + """Test that Home Assistant still does not support Pydantic 2.""" + """For PR#99077 and validate_service_type backport""" + assert pydantic.__version__ < "2" diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 923a633e76a..a33ca702bcf 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -1,6 +1,7 @@ """Integration tests for the auth component.""" from datetime import timedelta from http import HTTPStatus +import logging from unittest.mock import patch import pytest @@ -519,6 +520,106 @@ async def test_ws_delete_refresh_token( assert refresh_token is None +async def test_ws_delete_all_refresh_tokens_error( + hass: HomeAssistant, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, + hass_ws_client: WebSocketGenerator, + hass_access_token: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deleting all refresh tokens, where a revoke callback raises an error.""" + 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 + ) + token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID + "_1", credential=hass_admin_credential + ) + + def cb(): + raise RuntimeError("I'm bad") + + hass.auth.async_register_revoke_token_callback(token.id, cb) + + ws_client = await hass_ws_client(hass, hass_access_token) + + # get all tokens + await ws_client.send_json({"id": 5, "type": "auth/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", + } + ) + + caplog.clear() + result = await ws_client.receive_json() + assert result, result["success"] is False + assert result["error"] == { + "code": "token_removing_error", + "message": "During removal, an error was raised.", + } + + assert ( + "homeassistant.components.auth", + logging.ERROR, + "During refresh token removal, the following error occurred: I'm bad", + ) in caplog.record_tuples + + for token in tokens: + refresh_token = await hass.auth.async_get_refresh_token(token["id"]) + assert refresh_token is None + + +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, +) -> None: + """Test deleting all 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 + ) + await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID + "_1", credential=hass_admin_credential + ) + + ws_client = await hass_ws_client(hass, hass_access_token) + + # get all tokens + await ws_client.send_json({"id": 5, "type": "auth/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", + } + ) + + 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 + + async def test_ws_sign_path( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_access_token: str ) -> None: diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 5c9c4e5a255..3c476705258 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -60,6 +60,7 @@ def config_entry_fixture(hass, config, options, config_entry_version): """Define a config entry fixture.""" entry = MockConfigEntry( domain=AXIS_DOMAIN, + entry_id="676abe5b73621446e6550a2e86ffe3dd", unique_id=FORMATTED_MAC, data=config, options=options, diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..74a1f110c14 --- /dev/null +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -0,0 +1,92 @@ +# serializer version: 1 +# name: test_entry_diagnostics[api_discovery_items0] + dict({ + 'api_discovery': list([ + dict({ + 'id': 'api-discovery', + 'name': 'API Discovery Service', + 'version': '1.0', + }), + dict({ + 'id': 'param-cgi', + 'name': 'Legacy Parameter Handling', + 'version': '1.0', + }), + dict({ + 'id': 'basic-device-info', + 'name': 'Basic Device Information', + 'version': '1.1', + }), + ]), + 'basic_device_info': dict({ + 'ProdNbr': 'M1065-LW', + 'ProdType': 'Network Camera', + 'SerialNumber': '**REDACTED**', + 'Version': '9.80.1', + }), + 'camera_sources': dict({ + 'Image': 'http://1.2.3.4:80/axis-cgi/jpg/image.cgi', + 'MJPEG': 'http://1.2.3.4:80/axis-cgi/mjpg/video.cgi', + 'Stream': 'rtsp://user:pass@1.2.3.4/axis-media/media.amp?videocodec=h264', + }), + 'config': dict({ + 'data': dict({ + 'host': '1.2.3.4', + 'model': 'model', + 'name': 'name', + 'password': '**REDACTED**', + 'port': 80, + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'axis', + 'entry_id': '676abe5b73621446e6550a2e86ffe3dd', + 'options': dict({ + 'events': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 3, + }), + 'params': dict({ + 'root.IOPort': dict({ + 'I0.Configurable': 'no', + 'I0.Direction': 'input', + 'I0.Input.Name': 'PIR sensor', + 'I0.Input.Trig': 'closed', + }), + 'root.Input': dict({ + 'NbrOfInputs': '1', + }), + 'root.Output': dict({ + 'NbrOfOutputs': '0', + }), + 'root.Properties': dict({ + 'API.HTTP.Version': '3', + 'API.Metadata.Metadata': 'yes', + 'API.Metadata.Version': '1.0', + 'EmbeddedDevelopment.Version': '2.16', + 'Firmware.BuildDate': 'Feb 15 2019 09:42', + 'Firmware.BuildNumber': '26', + 'Firmware.Version': '9.10.1', + 'Image.Format': 'jpeg,mjpeg,h264', + 'Image.NbrOfViews': '2', + 'Image.Resolution': '1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240', + 'Image.Rotation': '0,180', + 'System.SerialNumber': '**REDACTED**', + }), + 'root.StreamProfile': dict({ + 'MaxGroups': '26', + 'S0.Description': 'profile_1_description', + 'S0.Name': 'profile_1', + 'S0.Parameters': 'videocodec=h264', + 'S1.Description': 'profile_2_description', + 'S1.Name': 'profile_2', + 'S1.Parameters': 'videocodec=h265', + }), + }), + }) +# --- diff --git a/tests/components/axis/test_diagnostics.py b/tests/components/axis/test_diagnostics.py index a76aa40ebc8..af11fdc388a 100644 --- a/tests/components/axis/test_diagnostics.py +++ b/tests/components/axis/test_diagnostics.py @@ -1,7 +1,7 @@ """Test Axis diagnostics.""" import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from .const import API_DISCOVERY_BASIC_DEVICE_INFO @@ -12,91 +12,13 @@ from tests.typing import ClientSessionGenerator @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_BASIC_DEVICE_INFO]) async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, setup_config_entry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, setup_config_entry - ) == { - "config": { - "entry_id": setup_config_entry.entry_id, - "version": 3, - "domain": "axis", - "title": "Mock Title", - "data": { - "host": "1.2.3.4", - "username": REDACTED, - "password": REDACTED, - "port": 80, - "model": "model", - "name": "name", - }, - "options": {"events": True}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "camera_sources": { - "Image": "http://1.2.3.4:80/axis-cgi/jpg/image.cgi", - "MJPEG": "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi", - "Stream": "rtsp://user:pass@1.2.3.4/axis-media/media.amp?videocodec=h264", - }, - "api_discovery": [ - { - "id": "api-discovery", - "name": "API Discovery Service", - "version": "1.0", - }, - { - "id": "param-cgi", - "name": "Legacy Parameter Handling", - "version": "1.0", - }, - { - "id": "basic-device-info", - "name": "Basic Device Information", - "version": "1.1", - }, - ], - "basic_device_info": { - "ProdNbr": "M1065-LW", - "ProdType": "Network Camera", - "SerialNumber": REDACTED, - "Version": "9.80.1", - }, - "params": { - "root.IOPort": { - "I0.Configurable": "no", - "I0.Direction": "input", - "I0.Input.Name": "PIR sensor", - "I0.Input.Trig": "closed", - }, - "root.Input": {"NbrOfInputs": "1"}, - "root.Output": {"NbrOfOutputs": "0"}, - "root.Properties": { - "API.HTTP.Version": "3", - "API.Metadata.Metadata": "yes", - "API.Metadata.Version": "1.0", - "EmbeddedDevelopment.Version": "2.16", - "Firmware.BuildDate": "Feb 15 2019 09:42", - "Firmware.BuildNumber": "26", - "Firmware.Version": "9.10.1", - "Image.Format": "jpeg,mjpeg,h264", - "Image.NbrOfViews": "2", - "Image.Resolution": "1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240", - "Image.Rotation": "0,180", - "System.SerialNumber": REDACTED, - }, - "root.StreamProfile": { - "MaxGroups": "26", - "S0.Description": "profile_1_description", - "S0.Name": "profile_1", - "S0.Parameters": "videocodec=h264", - "S1.Description": "profile_2_description", - "S1.Name": "profile_2", - "S1.Parameters": "videocodec=h265", - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, setup_config_entry) + == snapshot + ) diff --git a/tests/components/baf/test_init.py b/tests/components/baf/test_init.py new file mode 100644 index 00000000000..c87237892ad --- /dev/null +++ b/tests/components/baf/test_init.py @@ -0,0 +1,43 @@ +"""Test the baf init flow.""" +from unittest.mock import patch + +from aiobafi6.exceptions import DeviceUUIDMismatchError +import pytest + +from homeassistant.components.baf.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import MOCK_UUID, MockBAFDevice + +from tests.common import MockConfigEntry + + +def _patch_device_init(side_effect=None): + """Mock out the BAF Device object.""" + + def _create_mock_baf(*args, **kwargs): + return MockBAFDevice(side_effect) + + return patch("homeassistant.components.baf.Device", _create_mock_baf) + + +async def test_config_entry_wrong_uuid( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test config entry enters setup retry when uuid mismatches.""" + mismatched_uuid = MOCK_UUID + "0" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_IP_ADDRESS: "127.0.0.1"}, unique_id=mismatched_uuid + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_device_init(DeviceUUIDMismatchError): + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + assert ( + "Unexpected device found at 127.0.0.1; expected 12340, found 1234" + in caplog.text + ) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 04447d0b3cc..e5da4582454 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -29,7 +29,7 @@ def client_fixture() -> Generator[MagicMock, None, None]: client = mock_balboa.return_value callback: list[Callable] = [] - def on(_, _callback: Callable): # pylint: disable=invalid-name + def on(_, _callback: Callable): callback.append(_callback) return lambda: None diff --git a/tests/components/blebox/conftest.py b/tests/components/blebox/conftest.py index 4bda47bb414..82698633c30 100644 --- a/tests/components/blebox/conftest.py +++ b/tests/components/blebox/conftest.py @@ -37,14 +37,14 @@ def setup_product_mock(category, feature_mocks, path=None): return product_mock -def mock_only_feature(spec, **kwargs): +def mock_only_feature(spec, set_spec: bool = True, **kwargs): """Mock just the feature, without the product setup.""" - return mock.create_autospec(spec, True, True, **kwargs) + return mock.create_autospec(spec, set_spec, True, **kwargs) -def mock_feature(category, spec, **kwargs): +def mock_feature(category, spec, set_spec: bool = True, **kwargs): """Mock a feature along with whole product setup.""" - feature_mock = mock_only_feature(spec, **kwargs) + feature_mock = mock_only_feature(spec, set_spec, **kwargs) feature_mock.async_update = AsyncMock() product = setup_product_mock(category, [feature_mock]) diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index a270b20855a..c96fbfbfc99 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1,15 +1,18 @@ """Tests for the Bluetooth integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging import time +from typing import Any from unittest.mock import MagicMock, patch from home_assistant_bluetooth import BluetoothServiceInfo import pytest from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntityDescription, ) @@ -22,16 +25,22 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.passive_update_processor import ( + STORAGE_KEY, PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntityDescription, +) +from homeassistant.config_entries import current_entry from homeassistant.const import UnitOfTemperature from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -41,7 +50,12 @@ from . import ( patch_all_discovered_devices, ) -from tests.common import MockEntityPlatform, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + MockEntityPlatform, + async_fire_time_changed, + async_test_home_assistant, +) _LOGGER = logging.getLogger(__name__) @@ -392,7 +406,7 @@ async def test_exception_from_update_method( """Generate mock data.""" nonlocal run_count run_count += 1 - if run_count == 1: + if run_count == 2: raise Exception("Test exception") return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE @@ -422,6 +436,7 @@ async def test_exception_from_update_method( processor.async_add_listener(MagicMock()) inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert processor.available is True # We should go unavailable once we get an exception @@ -459,7 +474,7 @@ async def test_bad_data_from_update_method( """Generate mock data.""" nonlocal run_count run_count += 1 - if run_count == 1: + if run_count == 2: return "bad_data" return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE @@ -489,6 +504,7 @@ async def test_bad_data_from_update_method( processor.async_add_listener(MagicMock()) inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + saved_callback(GENERIC_BLUETOOTH_SERVICE_INFO, BluetoothChange.ADVERTISEMENT) assert processor.available is True # We should go unavailable once we get bad data @@ -1092,6 +1108,18 @@ BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( ) +DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Test Device", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={}, + entity_names={}, + entity_descriptions={}, +) + + async def test_integration_multiple_entity_platforms( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, @@ -1118,21 +1146,21 @@ async def test_integration_multiple_entity_platforms( binary_sensor_processor = PassiveBluetoothDataProcessor( lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE ) - sesnor_processor = PassiveBluetoothDataProcessor( + sensor_processor = PassiveBluetoothDataProcessor( lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE ) coordinator.async_register_processor(binary_sensor_processor) - coordinator.async_register_processor(sesnor_processor) + coordinator.async_register_processor(sensor_processor) cancel_coordinator = coordinator.async_start() binary_sensor_processor.async_add_listener(MagicMock()) - sesnor_processor.async_add_listener(MagicMock()) + sensor_processor.async_add_listener(MagicMock()) mock_add_sensor_entities = MagicMock() mock_add_binary_sensor_entities = MagicMock() - sesnor_processor.async_add_entities_listener( + sensor_processor.async_add_entities_listener( PassiveBluetoothProcessorEntity, mock_add_sensor_entities, ) @@ -1146,14 +1174,14 @@ async def test_integration_multiple_entity_platforms( assert len(mock_add_binary_sensor_entities.mock_calls) == 1 assert len(mock_add_sensor_entities.mock_calls) == 1 - binary_sesnor_entities = [ + binary_sensor_entities = [ *mock_add_binary_sensor_entities.mock_calls[0][1][0], ] - sesnor_entities = [ + sensor_entities = [ *mock_add_sensor_entities.mock_calls[0][1][0], ] - sensor_entity_one: PassiveBluetoothProcessorEntity = sesnor_entities[0] + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] sensor_entity_one.hass = hass assert sensor_entity_one.available is True assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" @@ -1167,7 +1195,7 @@ async def test_integration_multiple_entity_platforms( key="pressure", device_id=None ) - binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sesnor_entities[ + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ 0 ] binary_sensor_entity_one.hass = hass @@ -1242,3 +1270,281 @@ async def test_exception_from_coordinator_update_method( assert processor.available is True unregister_processor() cancel_coordinator() + + +async def test_integration_multiple_entity_platforms_with_reload_and_restart( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, + hass_storage: dict[str, Any], +) -> None: + """Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms with reload.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + entry = MockConfigEntry(domain=DOMAIN, data={}) + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + current_entry.set(entry) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + binary_sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE, + BINARY_SENSOR_DOMAIN, + ) + sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE, SENSOR_DOMAIN + ) + + unregister_binary_sensor_processor = coordinator.async_register_processor( + binary_sensor_processor, BinarySensorEntityDescription + ) + unregister_sensor_processor = coordinator.async_register_processor( + sensor_processor, SensorEntityDescription + ) + cancel_coordinator = coordinator.async_start() + + binary_sensor_processor.async_add_listener(MagicMock()) + sensor_processor.async_add_listener(MagicMock()) + + mock_add_sensor_entities = MagicMock() + mock_add_binary_sensor_entities = MagicMock() + + sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_sensor_entities, + ) + binary_sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_binary_sensor_entities, + ) + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + # First call with just the remote sensor entities results in them being added + assert len(mock_add_binary_sensor_entities.mock_calls) == 1 + assert len(mock_add_sensor_entities.mock_calls) == 1 + + binary_sensor_entities = [ + *mock_add_binary_sensor_entities.mock_calls[0][1][0], + ] + sensor_entities = [ + *mock_add_sensor_entities.mock_calls[0][1][0], + ] + + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] + sensor_entity_one.hass = hass + assert sensor_entity_one.available is True + assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" + assert sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="pressure", device_id=None + ) + + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ + 0 + ] + binary_sensor_entity_one.hass = hass + assert binary_sensor_entity_one.available is True + assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" + assert binary_sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="motion", device_id=None + ) + cancel_coordinator() + unregister_binary_sensor_processor() + unregister_sensor_processor() + + mock_add_sensor_entities = MagicMock() + mock_add_binary_sensor_entities = MagicMock() + + current_entry.set(entry) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + binary_sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + BINARY_SENSOR_DOMAIN, + ) + sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + SENSOR_DOMAIN, + ) + + sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_sensor_entities, + ) + binary_sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_binary_sensor_entities, + ) + + unregister_binary_sensor_processor = coordinator.async_register_processor( + binary_sensor_processor, BinarySensorEntityDescription + ) + unregister_sensor_processor = coordinator.async_register_processor( + sensor_processor, SensorEntityDescription + ) + cancel_coordinator = coordinator.async_start() + + assert len(mock_add_binary_sensor_entities.mock_calls) == 1 + assert len(mock_add_sensor_entities.mock_calls) == 1 + + binary_sensor_entities = [ + *mock_add_binary_sensor_entities.mock_calls[0][1][0], + ] + sensor_entities = [ + *mock_add_sensor_entities.mock_calls[0][1][0], + ] + + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] + sensor_entity_one.hass = hass + assert sensor_entity_one.available is True + assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" + assert sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="pressure", device_id=None + ) + + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ + 0 + ] + binary_sensor_entity_one.hass = hass + assert binary_sensor_entity_one.available is True + assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" + assert binary_sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="motion", device_id=None + ) + + await hass.async_stop() + await hass.async_block_till_done() + + assert SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id] + assert BINARY_SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id] + + # We don't normally cancel or unregister these at stop, + # but since we are mocking a restart we need to cleanup + cancel_coordinator() + unregister_binary_sensor_processor() + unregister_sensor_processor() + + hass = await async_test_home_assistant(asyncio.get_running_loop()) + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + current_entry.set(entry) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + mock_add_sensor_entities = MagicMock() + mock_add_binary_sensor_entities = MagicMock() + + binary_sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + BINARY_SENSOR_DOMAIN, + ) + sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + SENSOR_DOMAIN, + ) + + sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_sensor_entities, + ) + binary_sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_binary_sensor_entities, + ) + + unregister_binary_sensor_processor = coordinator.async_register_processor( + binary_sensor_processor, BinarySensorEntityDescription + ) + unregister_sensor_processor = coordinator.async_register_processor( + sensor_processor, SensorEntityDescription + ) + cancel_coordinator = coordinator.async_start() + + assert len(mock_add_binary_sensor_entities.mock_calls) == 1 + assert len(mock_add_sensor_entities.mock_calls) == 1 + + binary_sensor_entities = [ + *mock_add_binary_sensor_entities.mock_calls[0][1][0], + ] + sensor_entities = [ + *mock_add_sensor_entities.mock_calls[0][1][0], + ] + + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] + sensor_entity_one.hass = hass + assert sensor_entity_one.available is False # service data not injected + assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" + assert sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="pressure", device_id=None + ) + + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ + 0 + ] + binary_sensor_entity_one.hass = hass + assert binary_sensor_entity_one.available is False # service data not injected + assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" + assert binary_sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="motion", device_id=None + ) + cancel_coordinator() + unregister_binary_sensor_processor() + unregister_sensor_processor() + await hass.async_stop() diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index 89a34682c1b..020e4c978ed 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -1,16 +1,7 @@ """Tests for the for the BMW Connected Drive integration.""" -from pathlib import Path -from urllib.parse import urlparse -from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.const import ( - REMOTE_SERVICE_POSITION_URL, - VEHICLE_CHARGING_DETAILS_URL, - VEHICLE_STATE_URL, - VEHICLES_URL, -) -import httpx +from bimmer_connected.const import REMOTE_SERVICE_BASE_URL, VEHICLE_CHARGING_BASE_URL import respx from homeassistant import config_entries @@ -23,12 +14,7 @@ from homeassistant.components.bmw_connected_drive.const import ( from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import ( - MockConfigEntry, - get_fixture_path, - load_json_array_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry FIXTURE_USER_INPUT = { CONF_USERNAME: "user@domain.com", @@ -54,88 +40,6 @@ FIXTURE_CONFIG_ENTRY = { "unique_id": f"{FIXTURE_USER_INPUT[CONF_REGION]}-{FIXTURE_USER_INPUT[CONF_REGION]}", } -FIXTURE_PATH = Path(get_fixture_path("", integration=BMW_DOMAIN)) -FIXTURE_FILES = { - "vehicles": sorted(FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles.json")), - "states": { - p.stem.split("_")[-1]: p - for p in FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles_state_*.json") - }, - "charging": { - p.stem.split("_")[-1]: p - for p in FIXTURE_PATH.rglob("*-eadrax-crccs_v2_vehicles_*.json") - }, -} - - -def vehicles_sideeffect(request: httpx.Request) -> httpx.Response: - """Return /vehicles response based on x-user-agent.""" - x_user_agent = request.headers.get("x-user-agent", "").split(";") - brand = x_user_agent[1] - vehicles = [] - for vehicle_file in FIXTURE_FILES["vehicles"]: - if vehicle_file.name.startswith(brand): - vehicles.extend( - load_json_array_fixture(vehicle_file, integration=BMW_DOMAIN) - ) - return httpx.Response(200, json=vehicles) - - -def vehicle_state_sideeffect(request: httpx.Request) -> httpx.Response: - """Return /vehicles/state response.""" - try: - state_file = FIXTURE_FILES["states"][request.headers["bmw-vin"]] - return httpx.Response( - 200, json=load_json_object_fixture(state_file, integration=BMW_DOMAIN) - ) - except KeyError: - return httpx.Response(404) - - -def vehicle_charging_sideeffect(request: httpx.Request) -> httpx.Response: - """Return /vehicles/state response.""" - try: - charging_file = FIXTURE_FILES["charging"][request.headers["bmw-vin"]] - return httpx.Response( - 200, json=load_json_object_fixture(charging_file, integration=BMW_DOMAIN) - ) - except KeyError: - return httpx.Response(404) - - -def mock_vehicles() -> respx.Router: - """Return mocked adapter for vehicles.""" - router = respx.mock(assert_all_called=False) - - # Get vehicle list - router.get(VEHICLES_URL).mock(side_effect=vehicles_sideeffect) - - # Get vehicle state - router.get(VEHICLE_STATE_URL).mock(side_effect=vehicle_state_sideeffect) - - # Get vehicle charging details - router.get(VEHICLE_CHARGING_DETAILS_URL).mock( - side_effect=vehicle_charging_sideeffect - ) - - # Get vehicle position after remote service - router.post(urlparse(REMOTE_SERVICE_POSITION_URL).netloc).mock( - httpx.Response( - 200, - json=load_json_object_fixture( - FIXTURE_PATH / "remote_service" / "eventposition.json", - integration=BMW_DOMAIN, - ), - ) - ) - - return router - - -async def mock_login(auth: MyBMWAuthentication) -> None: - """Mock a successful login.""" - auth.access_token = "SOME_ACCESS_TOKEN" - async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: """Mock a fully setup config entry and all components based on fixtures.""" @@ -147,3 +51,52 @@ async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: await hass.async_block_till_done() return mock_config_entry + + +def check_remote_service_call( + router: respx.MockRouter, + remote_service: str = None, + remote_service_params: dict = None, + remote_service_payload: dict = None, +): + """Check if the last call was a successful remote service call.""" + + # Check if remote service call was made correctly + if remote_service: + # Get remote service call + first_remote_service_call: respx.models.Call = next( + c + for c in router.calls + if c.request.url.path.startswith(REMOTE_SERVICE_BASE_URL) + or c.request.url.path.startswith( + VEHICLE_CHARGING_BASE_URL.replace("/{vin}", "") + ) + ) + assert ( + first_remote_service_call.request.url.path.endswith(remote_service) is True + ) + assert first_remote_service_call.has_response is True + assert first_remote_service_call.response.is_success is True + + # test params. + # we don't test payload as this creates a lot of noise in the tests + # and is end-to-end tested with the HA states + if remote_service_params: + assert ( + dict(first_remote_service_call.request.url.params.items()) + == remote_service_params + ) + + # Now check final result + last_event_status_call = next( + c for c in reversed(router.calls) if c.request.url.path.endswith("eventStatus") + ) + + assert last_event_status_call is not None + assert ( + last_event_status_call.request.url.path + == "/eadrax-vrccs/v3/presentation/remote-commands/eventStatus" + ) + assert last_event_status_call.has_response is True + assert last_event_status_call.response.is_success is True + assert last_event_status_call.response.json() == {"eventStatus": "EXECUTED"} diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index b65adb5b2c0..4191c7a4dd2 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,34 +1,39 @@ """Fixtures for BMW tests.""" -from unittest.mock import AsyncMock, Mock -from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus +from collections.abc import Generator + +from bimmer_connected.tests import ALL_CHARGING_SETTINGS, ALL_STATES +from bimmer_connected.tests.common import MyBMWMockRouter +from bimmer_connected.vehicle import remote_services import pytest - -from homeassistant.components.bmw_connected_drive.coordinator import ( - BMWDataUpdateCoordinator, -) - -from . import mock_login, mock_vehicles +import respx @pytest.fixture -async def bmw_fixture(monkeypatch): - """Patch the MyBMW Login and mock HTTP calls.""" - monkeypatch.setattr(MyBMWAuthentication, "login", mock_login) +def bmw_fixture( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> Generator[respx.MockRouter, None, None]: + """Patch MyBMW login API calls.""" - monkeypatch.setattr( - RemoteServices, - "trigger_remote_service", - AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})), + # we use the library's mock router to mock the API calls, but only with a subset of vehicles + router = MyBMWMockRouter( + vehicles_to_load=[ + "WBA00000000DEMO01", + "WBA00000000DEMO02", + "WBA00000000DEMO03", + "WBY00000000REXI01", + ], + states=ALL_STATES, + charging_settings=ALL_CHARGING_SETTINGS, ) + # we don't want to wait when triggering a remote service monkeypatch.setattr( - BMWDataUpdateCoordinator, - "async_update_listeners", - Mock(), + remote_services, + "_POLLING_CYCLE", + 0, ) - with mock_vehicles(): - yield mock_vehicles + with router: + yield router diff --git a/tests/components/bmw_connected_drive/fixtures/remote_service/eventposition.json b/tests/components/bmw_connected_drive/fixtures/remote_service/eventposition.json deleted file mode 100644 index 92d1a6a9db0..00000000000 --- a/tests/components/bmw_connected_drive/fixtures/remote_service/eventposition.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "positionData": { - "status": "OK", - "position": { - "latitude": 123.456, - "longitude": 34.5678, - "formattedAddress": "some_formatted_address", - "heading": 121 - } - }, - "errorDetails": null -} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json deleted file mode 100644 index af850f1ff2c..00000000000 --- a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-crccs_v2_vehicles_WBA00000000DEMO02.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { - "chargingMode": "Sofort laden", - "chargingModeSemantics": "Sofort laden", - "departureTimer": ["Aus"], - "departureTimerSemantics": "Aus", - "preconditionForDeparture": "Aus", - "showDepartureTimers": false - }, - "chargingFlap": { - "permanentlyUnlockLabel": "Aus" - }, - "chargingSettings": { - "acCurrentLimitLabel": "16A", - "acCurrentLimitLabelSemantics": "16 Ampere", - "chargingTargetLabel": "80%", - "dcLoudnessLabel": "Nicht begrenzt", - "unlockCableAutomaticallyLabel": "Aus" - } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "NO_PRESELECTION", - "endTimeSlot": "0001-01-01T00:00:00", - "startTimeSlot": "0001-01-01T00:00:00", - "type": "CHARGING_IMMEDIATELY" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [], - "id": 1, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 2, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "chargingFlapDetail": { - "isPermanentlyUnlock": false - }, - "chargingSettingsDetail": { - "acLimit": { - "current": { - "unit": "A", - "value": 16 - }, - "isUnlimited": false, - "max": 32, - "min": 6, - "values": [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 32] - }, - "chargingTarget": 80, - "dcLoudness": "UNLIMITED_LOUD", - "isUnlockCableActive": false, - "minChargingTargetToWarning": 0 - }, - "servicePack": "WAVE_01" -} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json deleted file mode 100644 index f954fb103ae..00000000000 --- a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "appVehicleType": "DEMO", - "attributes": { - "a4aType": "NOT_SUPPORTED", - "bodyType": "G26", - "brand": "BMW", - "color": 4284245350, - "countryOfOrigin": "DE", - "driveTrain": "ELECTRIC", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitRaw": "HU_MGU", - "headUnitType": "MGU", - "hmiVersion": "ID8", - "lastFetched": "2023-01-04T14:57:06.019Z", - "model": "i4 eDrive40", - "softwareVersionCurrent": { - "iStep": 470, - "puStep": { - "month": 11, - "year": 21 - }, - "seriesCluster": "G026" - }, - "softwareVersionExFactory": { - "iStep": 470, - "puStep": { - "month": 11, - "year": 21 - }, - "seriesCluster": "G026" - }, - "telematicsUnit": "WAVE01", - "year": 2021 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "lmmStatusReasons": [], - "mappingStatus": "CONFIRMED" - }, - "vin": "WBA00000000DEMO02" - } -] diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json b/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json deleted file mode 100644 index a0974854295..00000000000 --- a/tests/components/bmw_connected_drive/fixtures/vehicles/G26/bmw-eadrax-vcs_v4_vehicles_state_WBA00000000DEMO02.json +++ /dev/null @@ -1,317 +0,0 @@ -{ - "capabilities": { - "a4aType": "NOT_SUPPORTED", - "checkSustainabilityDPP": false, - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "digitalKey": { - "bookedServicePackage": "SMACC_1_5", - "readerGraphics": "readerGraphics", - "state": "ACTIVATED" - }, - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": true, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": true, - "isChargingLoudnessEnabled": true, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": true, - "isChargingSettingsEnabled": true, - "isChargingTargetSocEnabled": true, - "isClimateTimerWeeklyActive": false, - "isCustomerEsimSupported": true, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": true, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isPersonalPictureUploadSupported": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": true, - "isSustainabilityAccumulatedViewEnabled": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remote360": true, - "remoteChargingCommands": { - "chargingControl": ["START", "STOP"], - "flapControl": ["NOT_SUPPORTED"], - "plugControl": ["NOT_SUPPORTED"] - }, - "remoteSoftwareUpgrade": true, - "sendPoi": true, - "specialThemeSupport": [], - "speechThirdPartyAlexa": false, - "speechThirdPartyAlexaSDK": false, - "unlock": true, - "vehicleFinder": true, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "IMMEDIATE_CHARGING", - "chargingPreference": "NO_PRESELECTION", - "chargingSettings": { - "acCurrentLimit": 16, - "hospitality": "NO_ACTION", - "idcc": "UNLIMITED_LOUD", - "targetSoc": 80 - }, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { - "hour": 0, - "minute": 0 - }, - "timerWeekDays": [] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { - "hour": 0, - "minute": 0 - }, - "timerWeekDays": [] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { - "hour": 0, - "minute": 0 - }, - "timerWeekDays": [] - }, - { - "action": "DEACTIVATE", - "id": 4, - "timeStamp": { - "hour": 0, - "minute": 0 - }, - "timerWeekDays": [] - } - ] - }, - "checkControlMessages": [ - { - "severity": "LOW", - "type": "TIRE_PRESSURE" - } - ], - "climateControlState": { - "activity": "STANDBY" - }, - "climateTimers": [ - { - "departureTime": { - "hour": 0, - "minute": 0 - }, - "isWeeklyTimer": false, - "timerAction": "DEACTIVATE", - "timerWeekDays": [] - }, - { - "departureTime": { - "hour": 0, - "minute": 0 - }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": [] - }, - { - "departureTime": { - "hour": 0, - "minute": 0 - }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": [] - } - ], - "combustionFuelLevel": {}, - "currentMileage": 1121, - "doorsState": { - "combinedSecurityState": "LOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { - "lscPrivacyMode": "OFF" - }, - "electricChargingState": { - "chargingConnectionType": "UNKNOWN", - "chargingLevelPercent": 80, - "chargingStatus": "CHARGING", - "chargingTarget": 80, - "isChargerConnected": true, - "range": 472, - "remainingChargingMinutes": 10 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2023-01-04T14:57:06.386Z", - "lastUpdatedAt": "2023-01-04T14:57:06.407Z", - "location": { - "address": { - "formatted": "Am Olympiapark 1, 80809 München" - }, - "coordinates": { - "latitude": 48.177334, - "longitude": 11.556274 - }, - "heading": 180 - }, - "range": 472, - "requiredServices": [ - { - "dateTime": "2024-12-01T00:00:00.000Z", - "description": "", - "mileage": 50000, - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2024-12-01T00:00:00.000Z", - "description": "", - "mileage": 50000, - "status": "OK", - "type": "VEHICLE_TUV" - }, - { - "dateTime": "2024-12-01T00:00:00.000Z", - "description": "", - "mileage": 50000, - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "status": "OK", - "type": "TIRE_WEAR_REAR" - }, - { - "status": "OK", - "type": "TIRE_WEAR_FRONT" - } - ], - "tireState": { - "frontLeft": { - "details": { - "dimension": "225/35 R20 90Y XL", - "isOptimizedForOemBmw": true, - "manufacturer": "Pirelli", - "manufacturingWeek": 4021, - "mountingDate": "2022-03-07T00:00:00.000Z", - "partNumber": "2461756", - "season": 2, - "speedClassification": { - "atLeast": false, - "speedRating": 300 - }, - "treadDesign": "P-ZERO" - }, - "status": { - "currentPressure": 241, - "pressureStatus": 0, - "targetPressure": 269, - "wearStatus": 0 - } - }, - "frontRight": { - "details": { - "dimension": "225/35 R20 90Y XL", - "isOptimizedForOemBmw": true, - "manufacturer": "Pirelli", - "manufacturingWeek": 2419, - "mountingDate": "2022-03-07T00:00:00.000Z", - "partNumber": "2461756", - "season": 2, - "speedClassification": { - "atLeast": false, - "speedRating": 300 - }, - "treadDesign": "P-ZERO" - }, - "status": { - "currentPressure": 255, - "pressureStatus": 0, - "targetPressure": 269, - "wearStatus": 0 - } - }, - "rearLeft": { - "details": { - "dimension": "255/30 R20 92Y XL", - "isOptimizedForOemBmw": true, - "manufacturer": "Pirelli", - "manufacturingWeek": 1219, - "mountingDate": "2022-03-07T00:00:00.000Z", - "partNumber": "2461757", - "season": 2, - "speedClassification": { - "atLeast": false, - "speedRating": 300 - }, - "treadDesign": "P-ZERO" - }, - "status": { - "currentPressure": 324, - "pressureStatus": 0, - "targetPressure": 303, - "wearStatus": 0 - } - }, - "rearRight": { - "details": { - "dimension": "255/30 R20 92Y XL", - "isOptimizedForOemBmw": true, - "manufacturer": "Pirelli", - "manufacturingWeek": 1219, - "mountingDate": "2022-03-07T00:00:00.000Z", - "partNumber": "2461757", - "season": 2, - "speedClassification": { - "atLeast": false, - "speedRating": 300 - }, - "treadDesign": "P-ZERO" - }, - "status": { - "currentPressure": 331, - "pressureStatus": 0, - "targetPressure": 303, - "wearStatus": 0 - } - } - }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED" - } - } -} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json deleted file mode 100644 index 03bfc1cae04..00000000000 --- a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { - "showDepartureTimers": false - } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" -} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json deleted file mode 100644 index 145bc13378e..00000000000 --- a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { - "month": 11, - "year": 21 - }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { - "month": 3, - "year": 15 - }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "WBY00000000REXI01" - } -] diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json deleted file mode 100644 index adc2bde3650..00000000000 --- a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json +++ /dev/null @@ -1,206 +0,0 @@ -{ - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { - "hour": 7, - "minute": 35 - }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { - "hour": 18, - "minute": 0 - }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { - "hour": 7, - "minute": 0 - }, - "timerWeekDays": [] - }, - { - "action": "DEACTIVATE", - "id": 4, - "timerWeekDays": [] - } - ], - "reductionOfChargeCurrent": { - "end": { - "hour": 1, - "minute": 30 - }, - "start": { - "hour": 18, - "minute": 1 - } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { - "hour": 6, - "minute": 40 - }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { - "hour": 12, - "minute": 50 - }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { - "hour": 18, - "minute": 59 - }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { - "lscPrivacyMode": "OFF" - }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { - "roofState": "CLOSED", - "roofStateType": "SUN_ROOF" - }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } -} diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr index a7520a6bce0..51e15e5ff43 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_button.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr @@ -1,6 +1,66 @@ # serializer version: 1 # name: test_entity_state_attrs list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Flash lights', + 'icon': 'mdi:car-light-alert', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_flash_lights', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Sound horn', + 'icon': 'mdi:bullhorn', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_sound_horn', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Activate air conditioning', + 'icon': 'mdi:hvac', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_activate_air_conditioning', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Deactivate air conditioning', + 'icon': 'mdi:hvac-off', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Find vehicle', + 'icon': 'mdi:crosshairs-question', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_find_vehicle', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', @@ -64,11 +124,59 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Refresh from cloud', - 'icon': 'mdi:refresh', + 'friendly_name': 'M340i xDrive Flash lights', + 'icon': 'mdi:car-light-alert', }), 'context': , - 'entity_id': 'button.i4_edrive40_refresh_from_cloud', + 'entity_id': 'button.m340i_xdrive_flash_lights', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Sound horn', + 'icon': 'mdi:bullhorn', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_sound_horn', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Activate air conditioning', + 'icon': 'mdi:hvac', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_activate_air_conditioning', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Deactivate air conditioning', + 'icon': 'mdi:hvac-off', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Find vehicle', + 'icon': 'mdi:crosshairs-question', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_find_vehicle', 'last_changed': , 'last_updated': , 'state': 'unknown', @@ -121,17 +229,5 @@ 'last_updated': , 'state': 'unknown', }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Refresh from cloud', - 'icon': 'mdi:refresh', - }), - 'context': , - 'entity_id': 'button.i3_rex_refresh_from_cloud', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }), ]) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 5befe3f0dcf..70224b41ff5 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -150,7 +150,7 @@ 'UTC', ]), }), - 'activity': 'STANDBY', + 'activity': 'INACTIVE', 'activity_end_time': None, 'activity_end_time_no_tz': None, 'is_climate_on': False, @@ -205,6 +205,888 @@ }), ]), }), + 'data': dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'BLUETOOTH', + 'bodyType': 'I20', + 'brand': 'BMW_I', + 'color': 4285537312, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'iX xDrive50', + 'softwareVersionCurrent': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'capabilities': dict({ + 'a4aType': 'BLUETOOTH', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_2_UWB', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'inCarCamera': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'surroundViewRecorder': True, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 70, + }), + 'servicePack': 'WAVE_01', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'remainingFuelPercent': 10, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 70, + 'chargingStatus': 'CHARGING', + 'chargingTarget': 80, + 'isChargerConnected': True, + 'range': 340, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.371Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.383Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 340, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 261, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 269, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'LOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': '2022-07-10T11:10:00+00:00', + 'charging_start_time': None, + 'charging_start_time_no_tz': None, + 'charging_status': 'CHARGING', + 'charging_target': 80, + 'is_charger_connected': True, + 'remaining_battery_percent': 70, + 'remaining_fuel': list([ + None, + None, + ]), + 'remaining_fuel_percent': None, + 'remaining_range_electric': list([ + 340, + 'km', + ]), + 'remaining_range_fuel': list([ + None, + None, + ]), + 'remaining_range_total': list([ + 340, + 'km', + ]), + }), + 'has_combustion_drivetrain': False, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': True, + 'is_remote_charge_stop_enabled': True, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': True, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': True, + 'is_remote_set_target_soc_enabled': True, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': True, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 1121, + 'km', + ]), + 'name': 'iX xDrive50', + 'timestamp': '2023-01-04T14:57:06+00:00', + 'tires': dict({ + 'front_left': dict({ + 'current_pressure': 241, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': 241, + }), + 'front_right': dict({ + 'current_pressure': 241, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': 241, + }), + 'rear_left': dict({ + 'current_pressure': 261, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + 'rear_right': dict({ + 'current_pressure': 269, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': 269, + }), + }), + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': '**REDACTED**', + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'remote_service_position': None, + 'vehicle_update_timestamp': '2023-01-04T14:57:06+00:00', + }), + 'vin': '**REDACTED**', + }), + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + 'ac_current_limit': 16, + 'charging_mode': 'IMMEDIATE_CHARGING', + 'charging_preferences': 'NO_PRESELECTION', + 'charging_preferences_service_pack': 'WAVE_01', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 1, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 2, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '00:00:00', + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + }), + 'end_time': '00:00:00', + 'start_time': '00:00:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + dict({ + 'description_long': None, + 'description_short': 'TIRE_PRESSURE', + 'state': 'LOW', + }), + ]), + }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'HEATING', + 'activity_end_time': '2022-07-10T11:29:50+00:00', + 'activity_end_time_no_tz': '2022-07-10T11:29:50', + 'is_climate_on': True, + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_REAR', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_FRONT', + 'state': 'OK', + }), + ]), + }), 'data': dict({ 'appVehicleType': 'DEMO', 'attributes': dict({ @@ -289,16 +1171,6 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ - 'chargingControl': list([ - 'START', - 'STOP', - ]), - 'flapControl': list([ - 'NOT_SUPPORTED', - ]), - 'plugControl': list([ - 'NOT_SUPPORTED', - ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -481,7 +1353,8 @@ }), ]), 'climateControlState': dict({ - 'activity': 'STANDBY', + 'activity': 'HEATING', + 'remainingSeconds': 1790.846, }), 'climateTimers': list([ dict({ @@ -534,9 +1407,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'CHARGING', + 'chargingStatus': 'INVALID', 'chargingTarget': 80, - 'isChargerConnected': True, + 'isChargerConnected': False, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -788,9 +1661,9 @@ 'charging_end_time': '2022-07-10T11:10:00+00:00', 'charging_start_time': None, 'charging_start_time_no_tz': None, - 'charging_status': 'CHARGING', + 'charging_status': 'NOT_CHARGING', 'charging_target': 80, - 'is_charger_connected': True, + 'is_charger_connected': False, 'remaining_battery_percent': 80, 'remaining_fuel': list([ None, @@ -814,8 +1687,8 @@ 'has_electric_drivetrain': True, 'is_charging_plan_supported': True, 'is_lsc_enabled': True, - 'is_remote_charge_start_enabled': True, - 'is_remote_charge_stop_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, 'is_remote_climate_start_enabled': True, 'is_remote_climate_stop_enabled': True, 'is_remote_horn_enabled': True, @@ -872,6 +1745,639 @@ }), 'vin': '**REDACTED**', }), + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'IMMEDIATE_CHARGING', + 'charging_preferences': 'NO_PRESELECTION', + 'charging_preferences_service_pack': None, + 'departure_times': list([ + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + }), + 'end_time': '00:00:00', + 'start_time': '00:00:00', + }), + 'timer_type': 'UNKNOWN', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + dict({ + 'description_long': None, + 'description_short': 'TIRE_PRESSURE', + 'state': 'LOW', + }), + dict({ + 'description_long': None, + 'description_short': 'ENGINE_OIL', + 'state': 'LOW', + }), + ]), + }), + 'climate': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'activity': 'INACTIVE', + 'activity_end_time': None, + 'activity_end_time_no_tz': None, + 'is_climate_on': False, + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'OIL', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + dict({ + 'due_date': '2024-12-01T00:00:00+00:00', + 'due_distance': list([ + 50000, + 'km', + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_REAR', + 'state': 'OK', + }), + dict({ + 'due_date': None, + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'TIRE_WEAR_FRONT', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G20', + 'brand': 'BMW', + 'color': 4280233344, + 'countryOfOrigin': 'PT', + 'driveTrain': 'COMBUSTION', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID7', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'M340i xDrive', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S18A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 420, + 'puStep': dict({ + 'month': 7, + 'year': 20, + }), + 'seriesCluster': 'S18A', + }), + 'telematicsUnit': 'ATM2', + 'year': 2022, + }), + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'VENTILATION', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': False, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': False, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': False, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': False, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': True, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 0, + }), + 'departureTimes': list([ + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + dict({ + 'severity': 'LOW', + 'type': 'ENGINE_OIL', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 629, + 'remainingFuelLiters': 40, + 'remainingFuelPercent': 80, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.336Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.348Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 629, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'OIL', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'LOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'COMBUSTION', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': None, + 'charging_start_time_no_tz': None, + 'charging_status': None, + 'charging_target': None, + 'is_charger_connected': False, + 'remaining_battery_percent': None, + 'remaining_fuel': list([ + 40, + 'L', + ]), + 'remaining_fuel_percent': 80, + 'remaining_range_electric': list([ + None, + None, + ]), + 'remaining_range_fuel': list([ + 629, + 'km', + ]), + 'remaining_range_total': list([ + 629, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': False, + 'is_charging_plan_supported': False, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': True, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': True, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 1121, + 'km', + ]), + 'name': 'M340i xDrive', + 'timestamp': '2023-01-04T14:57:06+00:00', + 'tires': dict({ + 'front_left': dict({ + 'current_pressure': 241, + 'manufacturing_week': '2021-10-04T00:00:00', + 'season': 2, + 'target_pressure': None, + }), + 'front_right': dict({ + 'current_pressure': 255, + 'manufacturing_week': '2019-06-10T00:00:00', + 'season': 2, + 'target_pressure': None, + }), + 'rear_left': dict({ + 'current_pressure': 324, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': None, + }), + 'rear_right': dict({ + 'current_pressure': 331, + 'manufacturing_week': '2019-03-18T00:00:00', + 'season': 2, + 'target_pressure': None, + }), + }), + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': '**REDACTED**', + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'remote_service_position': None, + 'vehicle_update_timestamp': '2023-01-04T14:57:06+00:00', + }), + 'vin': '**REDACTED**', + }), dict({ 'available_attributes': list([ 'gps_position', @@ -1086,7 +2592,7 @@ }), 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', + 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -1215,8 +2721,6 @@ 'fetched_at': '2022-07-10T11:00:00+00:00', 'is_metric': True, 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', }), @@ -1333,7 +2837,6 @@ 'combustionFuelLevel': dict({ 'range': 105, 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, }), 'currentMileage': 137009, 'doorsState': dict({ @@ -1496,7 +2999,7 @@ 6, 'L', ]), - 'remaining_fuel_percent': 65, + 'remaining_fuel_percent': None, 'remaining_range_electric': list([ 174, 'km', @@ -1533,14 +3036,14 @@ 'km', ]), 'name': 'i3 (+ REX)', - 'timestamp': '2022-07-10T09:25:53+00:00', + 'timestamp': '2022-06-22T14:24:23+00:00', 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, 'location': None, 'remote_service_position': None, - 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_update_timestamp': '2022-06-22T14:24:23+00:00', }), 'vin': '**REDACTED**', }), @@ -1548,6 +3051,55 @@ 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'BLUETOOTH', + 'bodyType': 'I20', + 'brand': 'BMW_I', + 'color': 4285537312, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'iX xDrive50', + 'softwareVersionCurrent': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'DEMO', 'attributes': dict({ @@ -1597,6 +3149,55 @@ }), 'vin': '**REDACTED**', }), + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G20', + 'brand': 'BMW', + 'color': 4280233344, + 'countryOfOrigin': 'PT', + 'driveTrain': 'COMBUSTION', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID7', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'M340i xDrive', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S18A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 420, + 'puStep': dict({ + 'month': 7, + 'year': 20, + }), + 'seriesCluster': 'S18A', + }), + 'telematicsUnit': 'ATM2', + 'year': 2022, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -1614,7 +3215,7 @@ }), 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', + 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -1635,8 +3236,6 @@ 'year': 2015, }), 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', }), @@ -1650,6 +3249,452 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'BLUETOOTH', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_2_UWB', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'inCarCamera': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'surroundViewRecorder': True, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'remainingFuelPercent': 10, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 70, + 'chargingStatus': 'CHARGING', + 'chargingTarget': 80, + 'isChargerConnected': True, + 'range': 340, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.371Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.383Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 340, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 261, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 269, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 70, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -1697,16 +3742,6 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ - 'chargingControl': list([ - 'START', - 'STOP', - ]), - 'flapControl': list([ - 'NOT_SUPPORTED', - ]), - 'plugControl': list([ - 'NOT_SUPPORTED', - ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -1779,7 +3814,8 @@ }), ]), 'climateControlState': dict({ - 'activity': 'STANDBY', + 'activity': 'HEATING', + 'remainingSeconds': 1790.846, }), 'climateTimers': list([ dict({ @@ -1832,9 +3868,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'CHARGING', + 'chargingStatus': 'INVALID', 'chargingTarget': 80, - 'isChargerConnected': True, + 'isChargerConnected': False, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -1984,7 +4020,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -2087,7 +4123,294 @@ }), 'servicePack': 'WAVE_01', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT02.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'VENTILATION', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': False, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': False, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': False, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': False, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': True, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 0, + }), + 'departureTimes': list([ + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + dict({ + 'severity': 'LOW', + 'type': 'ENGINE_OIL', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 629, + 'remainingFuelLiters': 40, + 'remainingFuelPercent': 80, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.336Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.348Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 629, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'OIL', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT03.json', }), dict({ 'content': dict({ @@ -2248,7 +4571,6 @@ 'combustionFuelLevel': dict({ 'range': 105, 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, }), 'currentMileage': 137009, 'doorsState': dict({ @@ -2308,7 +4630,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ 'content': dict({ @@ -2373,7 +4695,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', }), ]), 'info': dict({ @@ -2601,7 +4923,7 @@ }), 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', + 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -2730,8 +5052,6 @@ 'fetched_at': '2022-07-10T11:00:00+00:00', 'is_metric': True, 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', }), @@ -2848,7 +5168,6 @@ 'combustionFuelLevel': dict({ 'range': 105, 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, }), 'currentMileage': 137009, 'doorsState': dict({ @@ -3011,7 +5330,7 @@ 6, 'L', ]), - 'remaining_fuel_percent': 65, + 'remaining_fuel_percent': None, 'remaining_range_electric': list([ 174, 'km', @@ -3048,20 +5367,69 @@ 'km', ]), 'name': 'i3 (+ REX)', - 'timestamp': '2022-07-10T09:25:53+00:00', + 'timestamp': '2022-06-22T14:24:23+00:00', 'tires': None, 'vehicle_location': dict({ 'account_region': 'row', 'heading': None, 'location': None, 'remote_service_position': None, - 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_update_timestamp': '2022-06-22T14:24:23+00:00', }), 'vin': '**REDACTED**', }), 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'BLUETOOTH', + 'bodyType': 'I20', + 'brand': 'BMW_I', + 'color': 4285537312, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'iX xDrive50', + 'softwareVersionCurrent': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'DEMO', 'attributes': dict({ @@ -3111,6 +5479,55 @@ }), 'vin': '**REDACTED**', }), + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G20', + 'brand': 'BMW', + 'color': 4280233344, + 'countryOfOrigin': 'PT', + 'driveTrain': 'COMBUSTION', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID7', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'M340i xDrive', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S18A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 420, + 'puStep': dict({ + 'month': 7, + 'year': 20, + }), + 'seriesCluster': 'S18A', + }), + 'telematicsUnit': 'ATM2', + 'year': 2022, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -3128,7 +5545,7 @@ }), 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', + 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -3149,8 +5566,6 @@ 'year': 2015, }), 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', }), @@ -3164,6 +5579,452 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'BLUETOOTH', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_2_UWB', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'inCarCamera': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'surroundViewRecorder': True, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'remainingFuelPercent': 10, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 70, + 'chargingStatus': 'CHARGING', + 'chargingTarget': 80, + 'isChargerConnected': True, + 'range': 340, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.371Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.383Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 340, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 261, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 269, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 70, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -3211,16 +6072,6 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ - 'chargingControl': list([ - 'START', - 'STOP', - ]), - 'flapControl': list([ - 'NOT_SUPPORTED', - ]), - 'plugControl': list([ - 'NOT_SUPPORTED', - ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -3293,7 +6144,8 @@ }), ]), 'climateControlState': dict({ - 'activity': 'STANDBY', + 'activity': 'HEATING', + 'remainingSeconds': 1790.846, }), 'climateTimers': list([ dict({ @@ -3346,9 +6198,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'CHARGING', + 'chargingStatus': 'INVALID', 'chargingTarget': 80, - 'isChargerConnected': True, + 'isChargerConnected': False, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -3498,7 +6350,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -3601,7 +6453,294 @@ }), 'servicePack': 'WAVE_01', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT02.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'VENTILATION', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': False, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': False, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': False, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': False, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': True, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 0, + }), + 'departureTimes': list([ + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + dict({ + 'severity': 'LOW', + 'type': 'ENGINE_OIL', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 629, + 'remainingFuelLiters': 40, + 'remainingFuelPercent': 80, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.336Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.348Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 629, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'OIL', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT03.json', }), dict({ 'content': dict({ @@ -3762,7 +6901,6 @@ 'combustionFuelLevel': dict({ 'range': 105, 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, }), 'currentMileage': 137009, 'doorsState': dict({ @@ -3822,7 +6960,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ 'content': dict({ @@ -3887,7 +7025,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', }), ]), 'info': dict({ @@ -3905,6 +7043,55 @@ 'fingerprint': list([ dict({ 'content': list([ + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'BLUETOOTH', + 'bodyType': 'I20', + 'brand': 'BMW_I', + 'color': 4285537312, + 'countryOfOrigin': 'DE', + 'driveTrain': 'ELECTRIC', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID8', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'iX xDrive50', + 'softwareVersionCurrent': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 300, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S21A', + }), + 'telematicsUnit': 'WAVE01', + 'year': 2021, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'DEMO', 'attributes': dict({ @@ -3954,6 +7141,55 @@ }), 'vin': '**REDACTED**', }), + dict({ + 'appVehicleType': 'DEMO', + 'attributes': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'bodyType': 'G20', + 'brand': 'BMW', + 'color': 4280233344, + 'countryOfOrigin': 'PT', + 'driveTrain': 'COMBUSTION', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitRaw': 'HU_MGU', + 'headUnitType': 'MGU', + 'hmiVersion': 'ID7', + 'lastFetched': '2023-01-04T14:57:06.019Z', + 'model': 'M340i xDrive', + 'softwareVersionCurrent': dict({ + 'iStep': 470, + 'puStep': dict({ + 'month': 7, + 'year': 21, + }), + 'seriesCluster': 'S18A', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 420, + 'puStep': dict({ + 'month': 7, + 'year': 20, + }), + 'seriesCluster': 'S18A', + }), + 'telematicsUnit': 'ATM2', + 'year': 2022, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'lmmStatusReasons': list([ + ]), + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), dict({ 'appVehicleType': 'CONNECTED', 'attributes': dict({ @@ -3971,7 +7207,7 @@ }), 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-07-10T09:25:53.104Z', + 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -3992,8 +7228,6 @@ 'year': 2015, }), 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, 'isPrimaryUser': True, 'mappingStatus': 'CONFIRMED', }), @@ -4007,6 +7241,452 @@ ]), 'filename': 'mini-eadrax-vcs_v4_vehicles.json', }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'BLUETOOTH', + 'checkSustainabilityDPP': False, + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_2_UWB', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'inCarCamera': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': True, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': True, + 'isChargingLoudnessEnabled': True, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': True, + 'isChargingSettingsEnabled': True, + 'isChargingTargetSocEnabled': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': True, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': True, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': True, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'surroundViewRecorder': True, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'acCurrentLimit': 16, + 'hospitality': 'NO_ACTION', + 'idcc': 'UNLIMITED_LOUD', + 'targetSoc': 80, + }), + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timeStamp': dict({ + 'hour': 0, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'remainingFuelPercent': 10, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'UNKNOWN', + 'chargingLevelPercent': 70, + 'chargingStatus': 'CHARGING', + 'chargingTarget': 80, + 'isChargerConnected': True, + 'range': 340, + 'remainingChargingMinutes': 10, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.371Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.383Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 340, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'targetPressure': 241, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 261, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '275/40 R22 107Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-04-20T00:00:00.000Z', + 'partNumber': '5A401A1', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 269, + 'pressureStatus': 0, + 'targetPressure': 269, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'chargingMode': 'Sofort laden', + 'chargingModeSemantics': 'Sofort laden', + 'departureTimer': list([ + 'Aus', + ]), + 'departureTimerSemantics': 'Aus', + 'preconditionForDeparture': 'Aus', + 'showDepartureTimers': False, + }), + 'chargingFlap': dict({ + 'permanentlyUnlockLabel': 'Aus', + }), + 'chargingSettings': dict({ + 'acCurrentLimitLabel': '16A', + 'acCurrentLimitLabelSemantics': '16 Ampere', + 'chargingTargetLabel': '80%', + 'dcLoudnessLabel': 'Nicht begrenzt', + 'unlockCableAutomaticallyLabel': 'Aus', + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'NO_PRESELECTION', + 'endTimeSlot': '0001-01-01T00:00:00', + 'startTimeSlot': '0001-01-01T00:00:00', + 'type': 'CHARGING_IMMEDIATELY', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 1, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 2, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'chargingFlapDetail': dict({ + 'isPermanentlyUnlock': False, + }), + 'chargingSettingsDetail': dict({ + 'acLimit': dict({ + 'current': dict({ + 'unit': 'A', + 'value': 16, + }), + 'isUnlimited': False, + 'max': 32, + 'min': 6, + 'values': list([ + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 20, + 32, + ]), + }), + 'chargingTarget': 80, + 'dcLoudness': 'UNLIMITED_LOUD', + 'isUnlockCableActive': False, + 'minChargingTargetToWarning': 70, + }), + 'servicePack': 'WAVE_01', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + }), dict({ 'content': dict({ 'capabilities': dict({ @@ -4054,16 +7734,6 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ - 'chargingControl': list([ - 'START', - 'STOP', - ]), - 'flapControl': list([ - 'NOT_SUPPORTED', - ]), - 'plugControl': list([ - 'NOT_SUPPORTED', - ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -4136,7 +7806,8 @@ }), ]), 'climateControlState': dict({ - 'activity': 'STANDBY', + 'activity': 'HEATING', + 'remainingSeconds': 1790.846, }), 'climateTimers': list([ dict({ @@ -4189,9 +7860,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'CHARGING', + 'chargingStatus': 'INVALID', 'chargingTarget': 80, - 'isChargerConnected': True, + 'isChargerConnected': False, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -4341,7 +8012,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT02.json', }), dict({ 'content': dict({ @@ -4444,7 +8115,294 @@ }), 'servicePack': 'WAVE_01', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT01.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBA0FINGERPRINT02.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'a4aType': 'NOT_SUPPORTED', + 'checkSustainabilityDPP': False, + 'climateFunction': 'VENTILATION', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'digitalKey': dict({ + 'bookedServicePackage': 'SMACC_1_5', + 'readerGraphics': 'readerGraphics', + 'state': 'ACTIVATED', + }), + 'horn': True, + 'isBmwChargingSupported': False, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': False, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': False, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isClimateTimerWeeklyActive': False, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': False, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isPersonalPictureUploadSupported': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilityAccumulatedViewEnabled': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': True, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remote360': True, + 'remoteChargingCommands': dict({ + }), + 'remoteSoftwareUpgrade': True, + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'speechThirdPartyAlexa': False, + 'speechThirdPartyAlexaSDK': False, + 'unlock': True, + 'vehicleFinder': True, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingMode': 'IMMEDIATE_CHARGING', + 'chargingPreference': 'NO_PRESELECTION', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 0, + }), + 'departureTimes': list([ + ]), + }), + 'checkControlMessages': list([ + dict({ + 'severity': 'LOW', + 'type': 'TIRE_PRESSURE', + }), + dict({ + 'severity': 'LOW', + 'type': 'ENGINE_OIL', + }), + ]), + 'climateControlState': dict({ + 'activity': 'INACTIVE', + }), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': False, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 0, + 'minute': 0, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 629, + 'remainingFuelLiters': 40, + 'remainingFuelPercent': 80, + }), + 'currentMileage': 1121, + 'doorsState': dict({ + 'combinedSecurityState': 'LOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2023-01-04T14:57:06.336Z', + 'lastUpdatedAt': '2023-01-04T14:57:06.348Z', + 'location': dict({ + 'address': dict({ + 'formatted': '**REDACTED**', + }), + 'coordinates': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'heading': '**REDACTED**', + }), + 'range': 629, + 'requiredServices': list([ + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'OIL', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + dict({ + 'dateTime': '2024-12-01T00:00:00.000Z', + 'description': '', + 'mileage': 50000, + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_REAR', + }), + dict({ + 'status': 'OK', + 'type': 'TIRE_WEAR_FRONT', + }), + ]), + 'tireState': dict({ + 'frontLeft': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 4021, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 241, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'frontRight': dict({ + 'details': dict({ + 'dimension': '225/35 R20 90Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 2419, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461756', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 255, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearLeft': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 324, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + 'rearRight': dict({ + 'details': dict({ + 'dimension': '255/30 R20 92Y XL', + 'isOptimizedForOemBmw': True, + 'manufacturer': 'Pirelli', + 'manufacturingWeek': 1219, + 'mountingDate': '2022-03-07T00:00:00.000Z', + 'partNumber': '2461757', + 'season': 2, + 'speedClassification': dict({ + 'atLeast': False, + 'speedRating': 300, + }), + 'treadDesign': 'P-ZERO', + }), + 'status': dict({ + 'currentPressure': 331, + 'pressureStatus': 0, + 'wearStatus': 0, + }), + }), + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBA0FINGERPRINT03.json', }), dict({ 'content': dict({ @@ -4605,7 +8563,6 @@ 'combustionFuelLevel': dict({ 'range': 105, 'remainingFuelLiters': 6, - 'remainingFuelPercent': 65, }), 'currentMileage': 137009, 'doorsState': dict({ @@ -4665,7 +8622,7 @@ }), }), }), - 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT02.json', + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ 'content': dict({ @@ -4730,7 +8687,7 @@ }), 'servicePack': 'TCB1', }), - 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT02.json', + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', }), ]), 'info': dict({ diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr index a99d8bb3e0f..ab3668664f4 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_number.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -1,6 +1,23 @@ # serializer version: 1 # name: test_entity_state_attrs list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Target SoC', + 'icon': 'mdi:battery-charging-medium', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.ix_xdrive50_target_soc', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index 522e74c61e2..cac71c3049d 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -1,6 +1,50 @@ # serializer version: 1 # name: test_entity_state_attrs list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 AC Charging Limit', + 'icon': 'mdi:current-ac', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.ix_xdrive50_ac_charging_limit', + 'last_changed': , + 'last_updated': , + 'state': '16', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging Mode', + 'icon': 'mdi:vector-point-select', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.ix_xdrive50_charging_mode', + 'last_changed': , + 'last_updated': , + 'state': 'IMMEDIATE_CHARGING', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr index de5a44637c3..974f3d785ff 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -1,6 +1,30 @@ # serializer version: 1 # name: test_entity_state_attrs list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Climate', + 'icon': 'mdi:fan', + }), + 'context': , + 'entity_id': 'switch.ix_xdrive50_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.ix_xdrive50_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', @@ -11,19 +35,19 @@ 'entity_id': 'switch.i4_edrive40_climate', 'last_changed': , 'last_updated': , - 'state': 'off', + 'state': 'on', }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Charging', - 'icon': 'mdi:ev-station', + 'friendly_name': 'M340i xDrive Climate', + 'icon': 'mdi:fan', }), 'context': , - 'entity_id': 'switch.i4_edrive40_charging', + 'entity_id': 'switch.m340i_xdrive_climate', 'last_changed': , 'last_updated': , - 'state': 'on', + 'state': 'off', }), ]) # --- diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py index 236dd76ce9f..9cea5f2fd91 100644 --- a/tests/components/bmw_connected_drive/test_button.py +++ b/tests/components/bmw_connected_drive/test_button.py @@ -1,15 +1,16 @@ """Test BMW buttons.""" +from unittest.mock import AsyncMock + +from bimmer_connected.models import MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices import pytest import respx from syrupy.assertion import SnapshotAssertion -from homeassistant.components.bmw_connected_drive.coordinator import ( - BMWDataUpdateCoordinator, -) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError -from . import setup_mocked_integration +from . import check_remote_service_call, setup_mocked_integration async def test_entity_state_attrs( @@ -27,25 +28,22 @@ async def test_entity_state_attrs( @pytest.mark.parametrize( - ("entity_id"), + ("entity_id", "remote_service"), [ - ("button.i4_edrive40_flash_lights"), - ("button.i4_edrive40_sound_horn"), - ("button.i4_edrive40_activate_air_conditioning"), - ("button.i4_edrive40_deactivate_air_conditioning"), - ("button.i4_edrive40_find_vehicle"), + ("button.i4_edrive40_flash_lights", "light-flash"), + ("button.i4_edrive40_sound_horn", "horn-blow"), ], ) -async def test_update_triggers_success( +async def test_service_call_success( hass: HomeAssistant, entity_id: str, + remote_service: str, bmw_fixture: respx.Router, ) -> None: - """Test button press.""" + """Test successful button press.""" # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test await hass.services.async_call( @@ -54,26 +52,143 @@ async def test_update_triggers_success( blocking=True, target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 1 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 + check_remote_service_call(bmw_fixture, remote_service) -async def test_refresh_from_cloud( +async def test_service_call_fail( hass: HomeAssistant, bmw_fixture: respx.Router, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test button press for deprecated service.""" + """Test failed button press.""" # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + entity_id = "switch.i4_edrive40_climate" + old_value = hass.states.get(entity_id).state + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=MyBMWRemoteServiceError), + ) + + # Test + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "button", + "press", + blocking=True, + target={"entity_id": "button.i4_edrive40_activate_air_conditioning"}, + ) + assert hass.states.get(entity_id).state == old_value + + +@pytest.mark.parametrize( + ( + "entity_id", + "state_entity_id", + "new_value", + "old_value", + "remote_service", + "remote_service_params", + ), + [ + ( + "button.i4_edrive40_activate_air_conditioning", + "switch.i4_edrive40_climate", + "on", + "off", + "climate-now", + {"action": "START"}, + ), + ( + "button.i4_edrive40_deactivate_air_conditioning", + "switch.i4_edrive40_climate", + "off", + "on", + "climate-now", + {"action": "STOP"}, + ), + ( + "button.i4_edrive40_find_vehicle", + "device_tracker.i4_edrive40", + "not_home", + "home", + "vehicle-finder", + {}, + ), + ], +) +async def test_service_call_success_state_change( + hass: HomeAssistant, + entity_id: str, + state_entity_id: str, + new_value: str, + old_value: str, + remote_service: str, + remote_service_params: dict, + bmw_fixture: respx.Router, +) -> None: + """Test successful button press with state change.""" + + # Setup component + assert await setup_mocked_integration(hass) + hass.states.async_set(state_entity_id, old_value) + assert hass.states.get(state_entity_id).state == old_value # Test await hass.services.async_call( "button", "press", blocking=True, - target={"entity_id": "button.i4_edrive40_refresh_from_cloud"}, + target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 0 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 2 + check_remote_service_call(bmw_fixture, remote_service, remote_service_params) + assert hass.states.get(state_entity_id).state == new_value + + +@pytest.mark.parametrize( + ("entity_id", "state_entity_id", "new_attrs", "old_attrs"), + [ + ( + "button.i4_edrive40_find_vehicle", + "device_tracker.i4_edrive40", + {"latitude": 123.456, "longitude": 34.5678, "direction": 121}, + {"latitude": 48.177334, "longitude": 11.556274, "direction": 180}, + ), + ], +) +async def test_service_call_success_attr_change( + hass: HomeAssistant, + entity_id: str, + state_entity_id: str, + new_attrs: dict, + old_attrs: dict, + bmw_fixture: respx.Router, +) -> None: + """Test successful button press with attribute change.""" + + # Setup component + assert await setup_mocked_integration(hass) + + assert { + k: v + for k, v in hass.states.get(state_entity_id).attributes.items() + if k in old_attrs + } == old_attrs + + # Test + await hass.services.async_call( + "button", + "press", + blocking=True, + target={"entity_id": entity_id}, + ) + check_remote_service_call(bmw_fixture) + assert { + k: v + for k, v in hass.states.get(state_entity_id).attributes.items() + if k in new_attrs + } == new_attrs diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py new file mode 100644 index 00000000000..ab2d08376dd --- /dev/null +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -0,0 +1,92 @@ +"""Test BMW coordinator.""" +from datetime import timedelta +from unittest.mock import patch + +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from freezegun.api import FrozenDateTimeFactory +import respx + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import UpdateFailed + +from . import FIXTURE_CONFIG_ENTRY + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_update_success(hass: HomeAssistant, bmw_fixture: respx.Router) -> None: + """Test the reauth form.""" + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.data[config_entry.domain][config_entry.entry_id].last_update_success + is True + ) + + +async def test_update_failed( + hass: HomeAssistant, bmw_fixture: respx.Router, freezer: FrozenDateTimeFactory +) -> None: + """Test the reauth form.""" + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = hass.data[config_entry.domain][config_entry.entry_id] + + assert coordinator.last_update_success is True + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWAPIError("Test error"), + ): + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, UpdateFailed) is True + + +async def test_update_reauth( + hass: HomeAssistant, bmw_fixture: respx.Router, freezer: FrozenDateTimeFactory +) -> None: + """Test the reauth form.""" + config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = hass.data[config_entry.domain][config_entry.entry_id] + + assert coordinator.last_update_success is True + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWAuthError("Test error"), + ): + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, UpdateFailed) is True + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWAuthError("Test error"), + ): + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index d8cd5d47867..bcd880fa0a6 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -7,13 +7,10 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion -from homeassistant.components.bmw_connected_drive.coordinator import ( - BMWDataUpdateCoordinator, -) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import setup_mocked_integration +from . import check_remote_service_call, setup_mocked_integration async def test_entity_state_attrs( @@ -31,33 +28,36 @@ async def test_entity_state_attrs( @pytest.mark.parametrize( - ("entity_id", "value"), + ("entity_id", "new_value", "old_value", "remote_service"), [ - ("number.i4_edrive40_target_soc", "80"), + ("number.i4_edrive40_target_soc", "80", "100", "charging-settings"), ], ) -async def test_update_triggers_success( +async def test_service_call_success( hass: HomeAssistant, entity_id: str, - value: str, + new_value: str, + old_value: str, + remote_service: str, bmw_fixture: respx.Router, ) -> None: - """Test allowed values for number inputs.""" + """Test successful number change.""" # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + hass.states.async_set(entity_id, old_value) + assert hass.states.get(entity_id).state == old_value # Test await hass.services.async_call( "number", "set_value", - service_data={"value": value}, + service_data={"value": new_value}, blocking=True, target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 1 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 + check_remote_service_call(bmw_fixture, remote_service) + assert hass.states.get(entity_id).state == new_value @pytest.mark.parametrize( @@ -66,7 +66,7 @@ async def test_update_triggers_success( ("number.i4_edrive40_target_soc", "81"), ], ) -async def test_update_triggers_fail( +async def test_service_call_invalid_input( hass: HomeAssistant, entity_id: str, value: str, @@ -76,7 +76,7 @@ async def test_update_triggers_fail( # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + old_value = hass.states.get(entity_id).state # Test with pytest.raises(ValueError): @@ -87,8 +87,7 @@ async def test_update_triggers_fail( blocking=True, target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 0 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 + assert hass.states.get(entity_id).state == old_value @pytest.mark.parametrize( @@ -99,18 +98,19 @@ async def test_update_triggers_fail( (ValueError, ValueError), ], ) -async def test_update_triggers_exceptions( +async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test not allowed values for number inputs.""" + """Test exception handling.""" # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + entity_id = "number.i4_edrive40_target_soc" + old_value = hass.states.get(entity_id).state # Setup exception monkeypatch.setattr( @@ -126,7 +126,6 @@ async def test_update_triggers_exceptions( "set_value", service_data={"value": "80"}, blocking=True, - target={"entity_id": "number.i4_edrive40_target_soc"}, + target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 1 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 + assert hass.states.get(entity_id).state == old_value diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 97da6f81d6e..2dbe66139b2 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -7,13 +7,10 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion -from homeassistant.components.bmw_connected_drive.coordinator import ( - BMWDataUpdateCoordinator, -) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import setup_mocked_integration +from . import check_remote_service_call, setup_mocked_integration async def test_entity_state_attrs( @@ -31,44 +28,58 @@ async def test_entity_state_attrs( @pytest.mark.parametrize( - ("entity_id", "value"), + ("entity_id", "new_value", "old_value", "remote_service"), [ - ("select.i3_rex_charging_mode", "IMMEDIATE_CHARGING"), - ("select.i4_edrive40_ac_charging_limit", "16"), - ("select.i4_edrive40_charging_mode", "DELAYED_CHARGING"), + ( + "select.i3_rex_charging_mode", + "IMMEDIATE_CHARGING", + "DELAYED_CHARGING", + "charging-profile", + ), + ("select.i4_edrive40_ac_charging_limit", "12", "16", "charging-settings"), + ( + "select.i4_edrive40_charging_mode", + "DELAYED_CHARGING", + "IMMEDIATE_CHARGING", + "charging-profile", + ), ], ) -async def test_update_triggers_success( +async def test_service_call_success( hass: HomeAssistant, entity_id: str, - value: str, + new_value: str, + old_value: str, + remote_service: str, bmw_fixture: respx.Router, ) -> None: - """Test allowed values for select inputs.""" + """Test successful input change.""" # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + hass.states.async_set(entity_id, old_value) + assert hass.states.get(entity_id).state == old_value # Test await hass.services.async_call( "select", "select_option", - service_data={"option": value}, + service_data={"option": new_value}, blocking=True, target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 1 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 + check_remote_service_call(bmw_fixture, remote_service) + assert hass.states.get(entity_id).state == new_value @pytest.mark.parametrize( ("entity_id", "value"), [ ("select.i4_edrive40_ac_charging_limit", "17"), + ("select.i4_edrive40_charging_mode", "BONKERS_MODE"), ], ) -async def test_update_triggers_fail( +async def test_service_call_invalid_input( hass: HomeAssistant, entity_id: str, value: str, @@ -78,7 +89,7 @@ async def test_update_triggers_fail( # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + old_value = hass.states.get(entity_id).state # Test with pytest.raises(ValueError): @@ -89,8 +100,7 @@ async def test_update_triggers_fail( blocking=True, target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 0 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 + assert hass.states.get(entity_id).state == old_value @pytest.mark.parametrize( @@ -101,17 +111,19 @@ async def test_update_triggers_fail( (ValueError, ValueError), ], ) -async def test_remote_service_exceptions( +async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test exception handling for remote services.""" + """Test exception handling.""" # Setup component assert await setup_mocked_integration(hass) + entity_id = "select.i4_edrive40_ac_charging_limit" + old_value = hass.states.get(entity_id).state # Setup exception monkeypatch.setattr( @@ -127,6 +139,6 @@ async def test_remote_service_exceptions( "select_option", service_data={"option": "16"}, blocking=True, - target={"entity_id": "select.i4_edrive40_ac_charging_limit"}, + target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 1 + assert hass.states.get(entity_id).state == old_value diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 03f836529be..95b1145d9d6 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -26,8 +26,8 @@ from . import setup_mocked_integration ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.59", "gal"), ("sensor.i3_rex_remaining_range_fuel", METRIC, "105", "km"), ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.24", "mi"), - ("sensor.i3_rex_remaining_fuel_percent", METRIC, "65", "%"), - ("sensor.i3_rex_remaining_fuel_percent", IMPERIAL, "65", "%"), + ("sensor.m340i_xdrive_remaining_fuel_percent", METRIC, "80", "%"), + ("sensor.m340i_xdrive_remaining_fuel_percent", IMPERIAL, "80", "%"), ], ) async def test_unit_conversion( diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index 26de4d3b6e8..c050f4b6cc2 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -7,13 +7,10 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion -from homeassistant.components.bmw_connected_drive.coordinator import ( - BMWDataUpdateCoordinator, -) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import setup_mocked_integration +from . import check_remote_service_call, setup_mocked_integration async def test_entity_state_attrs( @@ -25,42 +22,45 @@ async def test_entity_state_attrs( # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Get all switch entities assert hass.states.async_all("switch") == snapshot @pytest.mark.parametrize( - ("entity_id", "value"), + ("entity_id", "new_value", "old_value", "remote_service", "remote_service_params"), [ - ("switch.i4_edrive40_climate", "ON"), - ("switch.i4_edrive40_climate", "OFF"), - ("switch.i4_edrive40_charging", "ON"), - ("switch.i4_edrive40_charging", "OFF"), + ("switch.i4_edrive40_climate", "on", "off", "climate-now", {"action": "START"}), + ("switch.i4_edrive40_climate", "off", "on", "climate-now", {"action": "STOP"}), + ("switch.iX_xdrive50_charging", "on", "off", "start-charging", {}), + ("switch.iX_xdrive50_charging", "off", "on", "stop-charging", {}), ], ) -async def test_update_triggers_success( +async def test_service_call_success( hass: HomeAssistant, entity_id: str, - value: str, + new_value: str, + old_value: str, + remote_service: str, + remote_service_params: dict, bmw_fixture: respx.Router, ) -> None: - """Test allowed values for switch inputs.""" + """Test successful switch change.""" # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + hass.states.async_set(entity_id, old_value) + assert hass.states.get(entity_id).state == old_value # Test await hass.services.async_call( "switch", - f"turn_{value.lower()}", + f"turn_{new_value}", blocking=True, target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 1 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 + check_remote_service_call(bmw_fixture, remote_service, remote_service_params) + assert hass.states.get(entity_id).state == new_value @pytest.mark.parametrize( @@ -71,18 +71,18 @@ async def test_update_triggers_success( (ValueError, ValueError), ], ) -async def test_update_triggers_exceptions( +async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test not allowed values for switch inputs.""" + """Test exception handling.""" # Setup component assert await setup_mocked_integration(hass) - BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + entity_id = "switch.i4_edrive40_climate" # Setup exception monkeypatch.setattr( @@ -91,20 +91,32 @@ async def test_update_triggers_exceptions( AsyncMock(side_effect=raised), ) + # Turning switch to ON + old_value = "off" + hass.states.async_set(entity_id, old_value) + assert hass.states.get(entity_id).state == old_value + # Test with pytest.raises(expected): await hass.services.async_call( "switch", "turn_on", blocking=True, - target={"entity_id": "switch.i4_edrive40_climate"}, + target={"entity_id": entity_id}, ) + assert hass.states.get(entity_id).state == old_value + + # Turning switch to OFF + old_value = "on" + hass.states.async_set(entity_id, old_value) + assert hass.states.get(entity_id).state == old_value + + # Test with pytest.raises(expected): await hass.services.async_call( "switch", "turn_off", blocking=True, - target={"entity_id": "switch.i4_edrive40_climate"}, + target={"entity_id": entity_id}, ) - assert RemoteServices.trigger_remote_service.call_count == 2 - assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 + assert hass.states.get(entity_id).state == old_value diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 9b2e82abc84..6fbcb928b5a 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -220,7 +220,7 @@ def patch_bond_action_returns_clientresponseerror(): return patch( "homeassistant.components.bond.Bond.action", side_effect=ClientResponseError( - request_info=None, history=None, code=405, message="Method Not Allowed" + request_info=None, history=None, status=405, message="Method Not Allowed" ), ) diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 7dbd6696e18..33919219301 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -159,6 +159,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) + config_entry.add_to_hass(hass) old_identifers = (DOMAIN, "device_id") new_identifiers = (DOMAIN, "ZXXX12345", "device_id") @@ -170,8 +171,6 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant) -> None: name="old", ) - config_entry.add_to_hass(hass) - with patch_bond_bridge(), patch_bond_version( return_value={ "bondid": "ZXXX12345", diff --git a/tests/components/brother/fixtures/diagnostics_data.json b/tests/components/brother/fixtures/diagnostics_data.json deleted file mode 100644 index fd22f861e8d..00000000000 --- a/tests/components/brother/fixtures/diagnostics_data.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "black_counter": null, - "black_ink": null, - "black_ink_remaining": null, - "black_ink_status": null, - "cyan_counter": null, - "bw_counter": 709, - "belt_unit_remaining_life": 97, - "belt_unit_remaining_pages": 48436, - "black_drum_counter": 1611, - "black_drum_remaining_life": 92, - "black_drum_remaining_pages": 16389, - "black_toner": 80, - "black_toner_remaining": 75, - "black_toner_status": 1, - "color_counter": 902, - "cyan_drum_counter": 1611, - "cyan_drum_remaining_life": 92, - "cyan_drum_remaining_pages": 16389, - "cyan_ink": null, - "cyan_ink_remaining": null, - "cyan_ink_status": null, - "cyan_toner": 10, - "cyan_toner_remaining": 10, - "cyan_toner_status": 1, - "drum_counter": 986, - "drum_remaining_life": 92, - "drum_remaining_pages": 11014, - "drum_status": 1, - "duplex_unit_pages_counter": 538, - "firmware": "1.17", - "fuser_remaining_life": 97, - "fuser_unit_remaining_pages": null, - "image_counter": null, - "laser_remaining_life": null, - "laser_unit_remaining_pages": 48389, - "magenta_counter": null, - "magenta_drum_counter": 1611, - "magenta_drum_remaining_life": 92, - "magenta_drum_remaining_pages": 16389, - "magenta_ink": null, - "magenta_ink_remaining": null, - "magenta_ink_status": null, - "magenta_toner": 10, - "magenta_toner_remaining": 8, - "magenta_toner_status": 2, - "model": "HL-L2340DW", - "page_counter": 986, - "pf_kit_1_remaining_life": 98, - "pf_kit_1_remaining_pages": 48741, - "pf_kit_mp_remaining_life": null, - "pf_kit_mp_remaining_pages": null, - "serial": "0123456789", - "status": "waiting", - "uptime": "2019-09-24T12:14:56+00:00", - "yellow_counter": null, - "yellow_drum_counter": 1611, - "yellow_drum_remaining_life": 92, - "yellow_drum_remaining_pages": 16389, - "yellow_ink": null, - "yellow_ink_remaining": null, - "yellow_ink_status": null, - "yellow_toner": 10, - "yellow_toner_remaining": 2, - "yellow_toner_status": 2 -} diff --git a/tests/components/brother/snapshots/test_diagnostics.ambr b/tests/components/brother/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1bff613e557 --- /dev/null +++ b/tests/components/brother/snapshots/test_diagnostics.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'belt_unit_remaining_life': 97, + 'belt_unit_remaining_pages': 48436, + 'black_counter': None, + 'black_drum_counter': 1611, + 'black_drum_remaining_life': 92, + 'black_drum_remaining_pages': 16389, + 'black_ink': None, + 'black_ink_remaining': None, + 'black_ink_status': None, + 'black_toner': 80, + 'black_toner_remaining': 75, + 'black_toner_status': 1, + 'bw_counter': 709, + 'color_counter': 902, + 'cyan_counter': None, + 'cyan_drum_counter': 1611, + 'cyan_drum_remaining_life': 92, + 'cyan_drum_remaining_pages': 16389, + 'cyan_ink': None, + 'cyan_ink_remaining': None, + 'cyan_ink_status': None, + 'cyan_toner': 10, + 'cyan_toner_remaining': 10, + 'cyan_toner_status': 1, + 'drum_counter': 986, + 'drum_remaining_life': 92, + 'drum_remaining_pages': 11014, + 'drum_status': 1, + 'duplex_unit_pages_counter': 538, + 'firmware': '1.17', + 'fuser_remaining_life': 97, + 'fuser_unit_remaining_pages': None, + 'image_counter': None, + 'laser_remaining_life': None, + 'laser_unit_remaining_pages': 48389, + 'magenta_counter': None, + 'magenta_drum_counter': 1611, + 'magenta_drum_remaining_life': 92, + 'magenta_drum_remaining_pages': 16389, + 'magenta_ink': None, + 'magenta_ink_remaining': None, + 'magenta_ink_status': None, + 'magenta_toner': 10, + 'magenta_toner_remaining': 8, + 'magenta_toner_status': 2, + 'model': 'HL-L2340DW', + 'page_counter': 986, + 'pf_kit_1_remaining_life': 98, + 'pf_kit_1_remaining_pages': 48741, + 'pf_kit_mp_remaining_life': None, + 'pf_kit_mp_remaining_pages': None, + 'serial': '0123456789', + 'status': 'waiting', + 'uptime': '2019-09-24T12:14:56+00:00', + 'yellow_counter': None, + 'yellow_drum_counter': 1611, + 'yellow_drum_remaining_life': 92, + 'yellow_drum_remaining_pages': 16389, + 'yellow_ink': None, + 'yellow_ink_remaining': None, + 'yellow_ink_status': None, + 'yellow_toner': 10, + 'yellow_toner_remaining': 2, + 'yellow_toner_status': 2, + }), + 'info': dict({ + 'host': 'localhost', + 'type': 'laser', + }), + }) +# --- diff --git a/tests/components/brother/test_diagnostics.py b/tests/components/brother/test_diagnostics.py index ce09fe13d1a..26ed77931b4 100644 --- a/tests/components/brother/test_diagnostics.py +++ b/tests/components/brother/test_diagnostics.py @@ -3,6 +3,8 @@ from datetime import datetime import json from unittest.mock import Mock, patch +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from homeassistant.util.dt import UTC @@ -14,12 +16,13 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass, skip_setup=True) - diagnostics_data = json.loads(load_fixture("diagnostics_data.json", "brother")) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) with patch("brother.Brother.initialize"), patch( "brother.datetime", now=Mock(return_value=test_time) @@ -32,5 +35,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result["info"] == {"host": "localhost", "type": "laser"} - assert result["data"] == diagnostics_data + assert result == snapshot diff --git a/tests/components/bsblan/fixtures/diagnostics.json b/tests/components/bsblan/fixtures/diagnostics.json deleted file mode 100644 index bd05aca56d5..00000000000 --- a/tests/components/bsblan/fixtures/diagnostics.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "info": { - "device_identification": { - "name": "Gerte-Identifikation", - "unit": "", - "desc": "", - "value": "RVS21.831F/127", - "dataType": 7 - }, - "controller_family": { - "name": "Device family", - "unit": "", - "desc": "", - "value": "211", - "dataType": 0 - }, - "controller_variant": { - "name": "Device variant", - "unit": "", - "desc": "", - "value": "127", - "dataType": 0 - } - }, - "device": { - "name": "BSB-LAN", - "version": "1.0.38-20200730234859", - "MAC": "00:80:41:19:69:90", - "uptime": 969402857 - }, - "state": { - "hvac_mode": { - "name": "Operating mode", - "unit": "", - "desc": "Komfort", - "value": "heat", - "dataType": 1 - }, - "hvac_mode2": { - "name": "Operating mode", - "unit": "", - "desc": "Reduziert", - "value": "2", - "dataType": 1 - }, - "target_temperature": { - "name": "Room temperature Comfort setpoint", - "unit": "°C", - "desc": "", - "value": "18.5", - "dataType": 0 - }, - "hvac_action": { - "name": "Status heating circuit 1", - "unit": "", - "desc": "Raumtemp\u2019begrenzung", - "value": "122", - "dataType": 1 - }, - "current_temperature": { - "name": "Room temp 1 actual value", - "unit": "°C", - "desc": "", - "value": "18.6", - "dataType": 0 - }, - "room1_thermostat_mode": { - "name": "Raumthermostat 1", - "unit": "", - "desc": "Kein Bedarf", - "value": "0", - "dataType": 1 - } - } -} diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b172d26c249 --- /dev/null +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -0,0 +1,78 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device': dict({ + 'MAC': '00:80:41:19:69:90', + 'name': 'BSB-LAN', + 'uptime': 969402857, + 'version': '1.0.38-20200730234859', + }), + 'info': dict({ + 'controller_family': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Device family', + 'unit': '', + 'value': '211', + }), + 'controller_variant': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Device variant', + 'unit': '', + 'value': '127', + }), + 'device_identification': dict({ + 'data_type': 7, + 'desc': '', + 'name': 'Gerte-Identifikation', + 'unit': '', + 'value': 'RVS21.831F/127', + }), + }), + 'state': dict({ + 'current_temperature': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Room temp 1 actual value', + 'unit': '°C', + 'value': '18.6', + }), + 'hvac_action': dict({ + 'data_type': 1, + 'desc': 'Raumtemp’begrenzung', + 'name': 'Status heating circuit 1', + 'unit': '', + 'value': '122', + }), + 'hvac_mode': dict({ + 'data_type': 1, + 'desc': 'Komfort', + 'name': 'Operating mode', + 'unit': '', + 'value': 'heat', + }), + 'hvac_mode2': dict({ + 'data_type': 1, + 'desc': 'Reduziert', + 'name': 'Operating mode', + 'unit': '', + 'value': '2', + }), + 'room1_thermostat_mode': dict({ + 'data_type': 1, + 'desc': 'Kein Bedarf', + 'name': 'Raumthermostat 1', + 'unit': '', + 'value': '0', + }), + 'target_temperature': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Room temperature Comfort setpoint', + 'unit': '°C', + 'value': '18.5', + }), + }), + }) +# --- diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index bcd6dec14b1..dce881f2f7d 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -3,17 +3,12 @@ from unittest.mock import AsyncMock, MagicMock from bsblan import BSBLANConnectionError -from homeassistant import data_entry_flow from homeassistant.components.bsblan import config_flow from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import ( - RESULT_TYPE_ABORT, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_FORM, -) +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac from tests.common import MockConfigEntry @@ -30,7 +25,7 @@ async def test_full_user_flow_implementation( context={"source": SOURCE_USER}, ) - assert result.get("type") == RESULT_TYPE_FORM + assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( @@ -44,7 +39,7 @@ async def test_full_user_flow_implementation( }, ) - assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("type") == FlowResultType.CREATE_ENTRY assert result2.get("title") == format_mac("00:80:41:19:69:90") assert result2.get("data") == { CONF_HOST: "127.0.0.1", @@ -68,7 +63,7 @@ async def test_show_user_form(hass: HomeAssistant) -> None: ) assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM async def test_connection_error( @@ -90,7 +85,7 @@ async def test_connection_error( }, ) - assert result.get("type") == RESULT_TYPE_FORM + assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {"base": "cannot_connect"} assert result.get("step_id") == "user" @@ -114,5 +109,5 @@ async def test_user_device_exists_abort( }, ) - assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index b2b5d201b93..316296df78a 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -1,9 +1,10 @@ """Tests for the diagnostics data provided by the BSBLan integration.""" -import json + +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -12,12 +13,11 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - diagnostics_fixture = json.loads(load_fixture("bsblan/diagnostics.json")) - assert ( await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - == diagnostics_fixture + == snapshot ) diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index cc5ad13dc80..168988e510f 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -308,3 +308,65 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_sleepy_device_restores_state(hass: HomeAssistant) -> None: + """Test sleepy device does not go to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + 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, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x11\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_opening") + + # Sleepy devices should keep their state over time + assert opening_sensor.state == STATE_ON + + 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() + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_opening") + + # Sleepy devices should keep their state on restore + assert opening_sensor.state == STATE_ON diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 4450bfcc936..831f7811972 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) -from homeassistant.components.bthome.const import DOMAIN +from homeassistant.components.bthome.const import CONF_SLEEPY_DEVICE, DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ( ATTR_FRIENDLY_NAME, @@ -869,7 +869,6 @@ async def test_v1_sensors( { "sensor_entity": "sensor.test_device_18b2_timestamp", "friendly_name": "Test Device 18B2 Timestamp", - "unit_of_measurement": "s", "state_class": "measurement", "expected_state": "2023-05-14T19:41:17+00:00", }, @@ -943,6 +942,21 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x53\x0C\x48\x65\x6C\x6C\x6F\x20\x57\x6F\x72\x6C\x64\x21", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_text", + "friendly_name": "Test Device 18B2 Text", + "expected_state": "Hello World!", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( @@ -1080,7 +1094,9 @@ async def test_v2_sensors( if ATTR_UNIT_OF_MEASUREMENT in sensor_attr: # Some sensors don't have a unit of measurement assert sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == meas["unit_of_measurement"] - assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] + if ATTR_STATE_CLASS in sensor_attr: + # Some sensors have state class None + assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -1138,6 +1154,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + assert CONF_SLEEPY_DEVICE not in entry.data + async def test_sleepy_device(hass: HomeAssistant) -> None: """Test sleepy device does not go to unavailable after 60 minutes.""" @@ -1191,3 +1209,69 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + assert entry.data[CONF_SLEEPY_DEVICE] is True + + +async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: + """Test sleepy device does not go to unavailable after 60 minutes and restores state.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + 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, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x04\x13\x8a\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + assert pressure_sensor.state == "1008.83" + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + # Sleepy devices should keep their state over time + assert pressure_sensor.state == "1008.83" + + 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() + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + # Sleepy devices should keep their state over time and restore it + assert pressure_sensor.state == "1008.83" + + assert entry.data[CONF_SLEEPY_DEVICE] is True diff --git a/tests/components/buienradar/test_sensor.py b/tests/components/buienradar/test_sensor.py index 725b03a6cc5..fb83d7a13db 100644 --- a/tests/components/buienradar/test_sensor.py +++ b/tests/components/buienradar/test_sensor.py @@ -1,4 +1,6 @@ """The tests for the Buienradar sensor platform.""" +from http import HTTPStatus + from homeassistant.components.buienradar.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -18,6 +20,9 @@ async def test_smoke_test_setup_component( aioclient_mock: AiohttpClientMocker, hass: HomeAssistant ) -> None: """Smoke test for successfully set-up with default config.""" + aioclient_mock.get( + "https://data.buienradar.nl/2.0/feed/json", status=HTTPStatus.NOT_FOUND + ) mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=TEST_CFG_DATA) mock_entry.add_to_hass(hass) diff --git a/tests/components/buienradar/test_weather.py b/tests/components/buienradar/test_weather.py index c8b0d459b78..d4c4af5f62a 100644 --- a/tests/components/buienradar/test_weather.py +++ b/tests/components/buienradar/test_weather.py @@ -1,4 +1,6 @@ """The tests for the buienradar weather component.""" +from http import HTTPStatus + from homeassistant.components.buienradar.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant @@ -13,6 +15,9 @@ async def test_smoke_test_setup_component( aioclient_mock: AiohttpClientMocker, hass: HomeAssistant ) -> None: """Smoke test for successfully set-up with default config.""" + aioclient_mock.get( + "https://data.buienradar.nl/2.0/feed/json", status=HTTPStatus.NOT_FOUND + ) mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=TEST_CFG_DATA) mock_entry.add_to_hass(hass) diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 45dd9d6afe1..02aebf3ce92 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -100,7 +100,7 @@ class FakeSchedule: async def fire_time(self, trigger_time: datetime.datetime) -> None: """Fire an alarm and wait.""" - _LOGGER.debug(f"Firing alarm @ {dt_util.as_local(trigger_time)}") + _LOGGER.debug("Firing alarm @ %s", dt_util.as_local(trigger_time)) self.freezer.move_to(trigger_time) async_fire_time_changed(self.hass, trigger_time) await self.hass.async_block_till_done() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 8d37eba219a..2a91a375a13 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -909,3 +909,61 @@ async def test_rtsp_to_web_rtc_offer_not_accepted( assert mock_provider.called unsub() + + +async def test_use_stream_for_stills( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_camera, +) -> None: + """Test that the component can grab images from stream.""" + + client = await hass_client() + + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value=None, + ) as mock_stream_source, patch( + "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", + return_value=True, + ): + # First test when the integration does not support stream should fail + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_stream_source.assert_not_called() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + # Test when the integration does not provide a stream_source should fail + with patch( + "homeassistant.components.demo.camera.DemoCamera.supported_features", + return_value=camera.SUPPORT_STREAM, + ): + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_stream_source.assert_called_once() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + + with patch( + "homeassistant.components.demo.camera.DemoCamera.stream_source", + return_value="rtsp://some_source", + ) as mock_stream_source, patch( + "homeassistant.components.camera.create_stream" + ) as mock_create_stream, patch( + "homeassistant.components.demo.camera.DemoCamera.supported_features", + return_value=camera.SUPPORT_STREAM, + ), patch( + "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", + return_value=True, + ): + # Now test when creating the stream succeeds + mock_stream = Mock() + mock_stream.async_get_image = AsyncMock() + mock_stream.async_get_image.return_value = b"stream_keyframe_image" + mock_create_stream.return_value = mock_stream + + # should start the stream and get the image + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_create_stream.assert_called_once() + mock_stream.async_get_image.assert_called_once() + assert resp.status == HTTPStatus.OK + assert await resp.read() == b"stream_keyframe_image" diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 6f7a13b47af..3d9feb3e43c 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -42,7 +42,6 @@ from tests.components.media_player import common from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator -# pylint: disable=invalid-name FakeUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e2") FakeUUID2 = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e4") FakeGroupUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e3") diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 2113ff5cc42..29fbf372ec4 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -99,7 +99,7 @@ async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == timestamp.isoformat() assert state.attributes.get("error") == "None" assert state.attributes.get("is_valid") @@ -107,12 +107,12 @@ async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is None @@ -129,7 +129,7 @@ async def test_delay_load_during_startup(hass: HomeAssistant) -> None: assert hass.state is CoreState.not_running assert entry.state is ConfigEntryState.LOADED - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is None timestamp = future_timestamp(100) @@ -142,7 +142,7 @@ async def test_delay_load_during_startup(hass: HomeAssistant) -> None: assert hass.state is CoreState.running - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == timestamp.isoformat() assert state.attributes.get("error") == "None" assert state.attributes.get("is_valid") diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 0fbf276cdea..e6a526c7c9e 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -36,7 +36,7 @@ async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -62,7 +62,7 @@ async def test_async_setup_entry_bad_cert(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.attributes.get("error") == "some error" @@ -90,7 +90,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -105,7 +105,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -134,7 +134,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -152,7 +152,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=48) - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( @@ -162,7 +162,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=48)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == timestamp.isoformat() @@ -178,7 +178,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=72)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state is not None assert state.state == STATE_UNKNOWN assert state.attributes.get("error") == "something bad" @@ -192,5 +192,5 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) await hass.async_block_till_done() - state = hass.states.get("sensor.cert_expiry_timestamp_example_com") + state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index 05c5c4cdebb..f56f499c935 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -5,9 +5,7 @@ import voluptuous_serialize import homeassistant.components.automation as automation from homeassistant.components.climate import DOMAIN, HVACMode, const, device_action -from homeassistant.components.device_automation import ( - DeviceAutomationType, -) +from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import ( diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 50cfce3f9a9..e205ba5f6e8 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -365,6 +365,11 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: response = await cloud.client.async_cloud_connection_info({}) assert response == { - "remote": {"connected": False, "enabled": False, "instance_domain": None}, + "remote": { + "connected": False, + "enabled": False, + "instance_domain": None, + "alias": None, + }, "version": HA_VERSION, } diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index ff79fd1ea77..fc6861f2b49 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,6 +1,7 @@ """Tests for the HTTP API for the cloud component.""" import asyncio from http import HTTPStatus +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import aiohttp @@ -24,7 +25,7 @@ from . import mock_cloud, mock_cloud_prefs from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import WebSocketGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/payments/subscription_info" @@ -1207,3 +1208,28 @@ async def test_tts_info( assert response["success"] assert response["result"] == {"languages": [["en-US", "male"], ["en-US", "female"]]} + + +@pytest.mark.parametrize( + ("endpoint", "data"), + [ + ("/api/cloud/forgot_password", {"email": "fake@example.com"}), + ("/api/cloud/google_actions/sync", None), + ("/api/cloud/login", {"email": "fake@example.com", "password": "secret"}), + ("/api/cloud/logout", None), + ("/api/cloud/register", {"email": "fake@example.com", "password": "secret"}), + ("/api/cloud/resend_confirm", {"email": "fake@example.com"}), + ], +) +async def test_api_calls_require_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, + endpoint: str, + data: dict[str, Any] | None, +) -> None: + """Test cloud APIs endpoints do not work as a normal user.""" + client = await hass_client(hass_read_only_access_token) + resp = await client.post(endpoint, json=data) + + assert resp.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d010cac77ad..f83de408bcc 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -123,6 +123,7 @@ async def test_legacy_subscription_repair_flow( "errors": None, "description_placeholders": None, "last_step": None, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -205,6 +206,7 @@ async def test_legacy_subscription_repair_flow_timeout( "errors": None, "description_placeholders": None, "last_step": None, + "preview": None, } with patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0): diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py index bc5d149e914..9207c1fef2c 100644 --- a/tests/components/cloud/test_subscription.py +++ b/tests/components/cloud/test_subscription.py @@ -16,7 +16,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker @pytest.fixture(name="mocked_cloud") -def mocked_cloud_object(hass: HomeAssistant) -> Cloud: +async def mocked_cloud_object(hass: HomeAssistant) -> Cloud: """Mock cloud object.""" return Mock( accounts_server="accounts.nabucasa.com", diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ffb35edfbbb --- /dev/null +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'location': '', + }), + 'disabled_by': None, + 'domain': 'co2signal', + 'entry_id': '904a74160aa6f335526706bee85dfb83', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + 'data': dict({ + 'countryCode': 'FR', + 'data': dict({ + 'carbonIntensity': 45.98623190095805, + 'fossilFuelPercentage': 5.461182741937103, + }), + 'status': 'ok', + 'units': dict({ + 'carbonIntensity': 'gCO2eq/kWh', + }), + }), + }) +# --- diff --git a/tests/components/co2signal/test_diagnostics.py b/tests/components/co2signal/test_diagnostics.py index c73409fa59b..ed73cb960b5 100644 --- a/tests/components/co2signal/test_diagnostics.py +++ b/tests/components/co2signal/test_diagnostics.py @@ -1,8 +1,9 @@ """Test the CO2Signal diagnostics.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.components.co2signal import DOMAIN -from homeassistant.components.diagnostics import REDACTED from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -15,11 +16,15 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_API_KEY: "api_key", "location": ""} + domain=DOMAIN, + data={CONF_API_KEY: "api_key", "location": ""}, + entry_id="904a74160aa6f335526706bee85dfb83", ) config_entry.add_to_hass(hass) with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD): @@ -27,10 +32,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - config_entry_dict = config_entry.as_dict() - config_entry_dict["data"][CONF_API_KEY] = REDACTED - - assert result == { - "config_entry": config_entry_dict, - "data": VALID_PAYLOAD, - } + assert result == snapshot diff --git a/tests/components/coinbase/common.py b/tests/components/coinbase/common.py index 4866039f310..6ab33f3bc7c 100644 --- a/tests/components/coinbase/common.py +++ b/tests/components/coinbase/common.py @@ -68,6 +68,7 @@ async def init_mock_coinbase(hass, currencies=None, rates=None): """Init Coinbase integration for testing.""" config_entry = MockConfigEntry( domain=DOMAIN, + entry_id="080272b77a4f80c41b94d7cdc86fd826", unique_id=None, title="Test User", data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 4db6abca37d..2b437e15478 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -1,6 +1,5 @@ """Constants for testing the Coinbase integration.""" -from homeassistant.components.diagnostics import REDACTED GOOD_CURRENCY = "BTC" GOOD_CURRENCY_2 = "USD" @@ -36,43 +35,3 @@ MOCK_ACCOUNTS_RESPONSE = [ "type": "fiat", }, ] - -MOCK_ACCOUNTS_RESPONSE_REDACTED = [ - { - "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, - "id": REDACTED, - "name": "BTC Wallet", - "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "type": "wallet", - }, - { - "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY}, - "currency": GOOD_CURRENCY, - "id": REDACTED, - "name": "BTC Vault", - "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "type": "vault", - }, - { - "balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "currency": "USD", - "id": REDACTED, - "name": "USD Wallet", - "native_balance": {"amount": REDACTED, "currency": GOOD_CURRENCY_2}, - "type": "fiat", - }, -] - -MOCK_ENTRY_REDACTED = { - "version": 1, - "domain": "coinbase", - "title": REDACTED, - "data": {"api_token": REDACTED, "api_key": REDACTED}, - "options": {"account_balance_currencies": [], "exchange_rate_currencies": []}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": None, - "disabled_by": None, -} diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c214330d5f9 --- /dev/null +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -0,0 +1,70 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'accounts': list([ + dict({ + 'balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'BTC', + }), + 'currency': 'BTC', + 'id': '**REDACTED**', + 'name': 'BTC Wallet', + 'native_balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'type': 'wallet', + }), + dict({ + 'balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'BTC', + }), + 'currency': 'BTC', + 'id': '**REDACTED**', + 'name': 'BTC Vault', + 'native_balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'type': 'vault', + }), + dict({ + 'balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'currency': 'USD', + 'id': '**REDACTED**', + 'name': 'USD Wallet', + 'native_balance': dict({ + 'amount': '**REDACTED**', + 'currency': 'USD', + }), + 'type': 'fiat', + }), + ]), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'api_token': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'coinbase', + 'entry_id': '080272b77a4f80c41b94d7cdc86fd826', + 'options': dict({ + 'account_balance_currencies': list([ + ]), + 'exchange_rate_currencies': list([ + ]), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/coinbase/test_diagnostics.py b/tests/components/coinbase/test_diagnostics.py index 73978790441..897722b32b4 100644 --- a/tests/components/coinbase/test_diagnostics.py +++ b/tests/components/coinbase/test_diagnostics.py @@ -1,6 +1,8 @@ """Test the Coinbase diagnostics.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from .common import ( @@ -9,14 +11,15 @@ from .common import ( mock_get_exchange_rates, mocked_get_accounts, ) -from .const import MOCK_ACCOUNTS_RESPONSE_REDACTED, MOCK_ENTRY_REDACTED 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 + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test we handle a and redact a diagnostics request.""" @@ -34,10 +37,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - # Remove the ID to match the constant - result["entry"].pop("entry_id") - - assert result == { - "entry": MOCK_ENTRY_REDACTED, - "accounts": MOCK_ACCOUNTS_RESPONSE_REDACTED, - } + assert result == snapshot diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py new file mode 100644 index 00000000000..916a684de4b --- /dev/null +++ b/tests/components/comelit/__init__.py @@ -0,0 +1 @@ +"""Tests for the Comelit SimpleHome integration.""" diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py new file mode 100644 index 00000000000..36955b0b0a9 --- /dev/null +++ b/tests/components/comelit/const.py @@ -0,0 +1,16 @@ +"""Common stuff for Comelit SimpleHome tests.""" +from homeassistant.components.comelit.const import DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_PIN: "1234", + } + ] + } +} + +MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py new file mode 100644 index 00000000000..2fb9e836efb --- /dev/null +++ b/tests/components/comelit/test_config_flow.py @@ -0,0 +1,154 @@ +"""Tests for Comelit SimpleHome config flow.""" +from unittest.mock import patch + +from aiocomelit import CannotAuthenticate, CannotConnect +import pytest + +from homeassistant.components.comelit.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_user(hass: HomeAssistant) -> None: + """Test starting a flow by user.""" + with patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", + ), patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + ), patch( + "homeassistant.components.comelit.async_setup_entry" + ) as mock_setup_entry, patch( + "requests.get" + ) as mock_request_get: + mock_request_get.return_value.status_code = 200 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PIN] == "1234" + assert not result["result"].unique_id + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a flow by user with a connection error.""" + + 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( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + +async def test_reauth_successful(hass: HomeAssistant) -> None: + """Test starting a reauthentication flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", + ), patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + ), patch("homeassistant.components.comelit.async_setup_entry"), patch( + "requests.get" + ) as mock_request_get: + mock_request_get.return_value.status_code = 200 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "other_fake_pin", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a reauthentication flow but no connection found.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.login", side_effect=side_effect + ), patch( + "aiocomelit.api.ComeliteSerialBridgeAPi.logout", + ), patch( + "homeassistant.components.comelit.async_setup_entry" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "other_fake_pin", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + 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 50971219f48..7d5db4603fe 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -246,7 +246,7 @@ async def test_updating_to_often( assert called async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=15)) wait_till_event.set() - asyncio.wait(0) + await asyncio.sleep(0) assert ( "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" not in caplog.text @@ -258,6 +258,7 @@ async def test_updating_to_often( await asyncio.sleep(0) async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) wait_till_event.set() + await asyncio.sleep(0) assert ( "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index db6dd277e1f..da2bf1f6dd9 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -581,7 +581,7 @@ async def test_updating_to_often( assert called async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=15)) wait_till_event.set() - asyncio.wait(0) + await asyncio.sleep(0) assert ( "Updating Command Line Sensor Test took longer than the scheduled update interval" @@ -594,6 +594,7 @@ async def test_updating_to_often( await asyncio.sleep(0) async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) wait_till_event.set() + await asyncio.sleep(0) assert ( "Updating Command Line Sensor Test took longer than the scheduled update interval" diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 11f17199e5a..abe0ed90e86 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -372,3 +372,35 @@ async def test_delete_automation( assert hass_config_store["automations.yaml"] == [{"id": "moon"}] assert len(ent_reg.entities) == 1 + + +@pytest.mark.parametrize("automation_config", ({},)) +async def test_api_calls_require_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, + hass_config_store, + setup_automation, +) -> None: + """Test cloud APIs endpoints do not work as a normal user.""" + with patch.object(config, "SECTIONS", ["automation"]): + await async_setup_component(hass, "config", {}) + + hass_config_store["automations.yaml"] = [{"id": "sun"}, {"id": "moon"}] + + client = await hass_client(hass_read_only_access_token) + + # Get + resp = await client.get("/api/config/automation/config/moon") + assert resp.status == HTTPStatus.UNAUTHORIZED + + # Update + resp = await client.post( + "/api/config/automation/config/moon", + data=json.dumps({"trigger": [], "action": [], "condition": []}), + ) + assert resp.status == HTTPStatus.UNAUTHORIZED + + # Delete + resp = await client.delete("/api/config/automation/config/sun") + assert resp.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bf94e36a9b4..4239e031893 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -396,6 +396,7 @@ async def test_initialize_flow(hass: HomeAssistant, client) -> None: }, "errors": {"username": "Should be unique."}, "last_step": None, + "preview": None, } @@ -571,6 +572,7 @@ async def test_two_step_flow( "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): @@ -647,6 +649,7 @@ async def test_continue_flow_unauth( "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } hass_admin_user.groups = [] @@ -822,9 +825,56 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: "description_placeholders": {"enabled": "Set to true to be true"}, "errors": None, "last_step": None, + "preview": None, } +@pytest.mark.parametrize( + ("endpoint", "method"), + [ + ("/api/config/config_entries/options/flow", "post"), + ("/api/config/config_entries/options/flow/1", "get"), + ("/api/config/config_entries/options/flow/1", "post"), + ], +) +async def test_options_flow_unauth( + hass: HomeAssistant, client, hass_admin_user: MockUser, endpoint: str, method: str +) -> None: + """Test unauthorized on options flow.""" + + class TestFlow(core_ce.ConfigFlow): + @staticmethod + @callback + def async_get_options_flow(config_entry): + class OptionsFlowHandler(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + return OptionsFlowHandler() + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + hass_admin_user.groups = [] + + with patch.dict(HANDLERS, {"test": TestFlow}): + resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.UNAUTHORIZED + + async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: """Test we can finish a two step options flow.""" mock_integration( @@ -871,6 +921,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): @@ -952,6 +1003,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 9612609c1c5..fa7f33858a6 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -60,6 +60,21 @@ async def test_validate_config_ok( assert result["errors"] == "beer" +async def test_validate_config_requires_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, +) -> None: + """Test checking configuration does not work as a normal user.""" + with patch.object(config, "SECTIONS", ["core"]): + await async_setup_component(hass, "config", {}) + + client = await hass_client(hass_read_only_access_token) + resp = await client.post("/api/config/core/check_config") + + assert resp.status == HTTPStatus.UNAUTHORIZED + + async def test_websocket_core_update(hass: HomeAssistant, client) -> None: """Test core config update websocket command.""" assert hass.config.latitude != 60 diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 25b465192cf..a92b2a353ef 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -26,15 +26,17 @@ async def test_list_devices( hass: HomeAssistant, client, device_registry: dr.DeviceRegistry ) -> None: """Test list entries.""" + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) device1 = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) device2 = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, identifiers={("bridgeid", "1234")}, manufacturer="manufacturer", model="model", @@ -50,7 +52,7 @@ async def test_list_devices( assert msg["result"] == [ { "area_id": None, - "config_entries": ["1234"], + "config_entries": [entry.entry_id], "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "disabled_by": None, @@ -66,7 +68,7 @@ async def test_list_devices( }, { "area_id": None, - "config_entries": ["1234"], + "config_entries": [entry.entry_id], "configuration_url": None, "connections": [], "disabled_by": None, @@ -94,7 +96,7 @@ async def test_list_devices( assert msg["result"] == [ { "area_id": None, - "config_entries": ["1234"], + "config_entries": [entry.entry_id], "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "disabled_by": None, @@ -135,8 +137,10 @@ async def test_update_device( payload_value, ) -> None: """Test update entry.""" + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) device = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 9cad68c9c99..a002f2c2d50 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -721,7 +721,7 @@ async def test_enable_entity_disabled_device( config_entry.add_to_hass(hass) device = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=config_entry.entry_id, connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", diff --git a/tests/components/config/test_scene.py b/tests/components/config/test_scene.py index d07db81b715..1f09d5e9989 100644 --- a/tests/components/config/test_scene.py +++ b/tests/components/config/test_scene.py @@ -221,3 +221,47 @@ async def test_delete_scene( ] assert len(ent_reg.entities) == 1 + + +@pytest.mark.parametrize("scene_config", ({},)) +async def test_api_calls_require_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, + hass_config_store, + setup_scene, +) -> None: + """Test scene APIs endpoints do not work as a normal user.""" + with patch.object(config, "SECTIONS", ["scene"]): + await async_setup_component(hass, "config", {}) + + hass_config_store["scenes.yaml"] = [ + { + "id": "light_off", + "name": "Lights off", + "entities": {"light.bedroom": {"state": "off"}}, + } + ] + + client = await hass_client(hass_read_only_access_token) + + # Get + resp = await client.get("/api/config/scene/config/light_off") + assert resp.status == HTTPStatus.UNAUTHORIZED + + # Update + resp = await client.post( + "/api/config/scene/config/light_off", + data=json.dumps( + { + "id": "light_off", + "name": "Lights off", + "entities": {"light.bedroom": {"state": "off"}}, + } + ), + ) + assert resp.status == HTTPStatus.UNAUTHORIZED + + # Delete + resp = await client.delete("/api/config/scene/config/light_on") + assert resp.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 86ea2cf9e7f..cc0352301b4 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -314,3 +314,36 @@ async def test_delete_script( assert hass_config_store["scripts.yaml"] == {"one": {}} assert len(ent_reg.entities) == 1 + + +@pytest.mark.parametrize("script_config", ({},)) +async def test_api_calls_require_admin( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_read_only_access_token: str, + hass_config_store, +) -> None: + """Test script APIs endpoints do not work as a normal user.""" + with patch.object(config, "SECTIONS", ["script"]): + await async_setup_component(hass, "config", {}) + + hass_config_store["scripts.yaml"] = { + "moon": {"alias": "Moon"}, + } + + client = await hass_client(hass_read_only_access_token) + + # Get + resp = await client.get("/api/config/script/config/moon") + assert resp.status == HTTPStatus.UNAUTHORIZED + + # Update + resp = await client.post( + "/api/config/script/config/moon", + data=json.dumps({"sequence": []}), + ) + assert resp.status == HTTPStatus.UNAUTHORIZED + + # Delete + resp = await client.delete("/api/config/script/config/moon") + assert resp.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index c3c2e621260..1677b254ff6 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -20,7 +20,7 @@ from homeassistant.setup import async_setup_component from . import expose_entity -from tests.common import async_mock_service +from tests.common import MockConfigEntry, async_mock_service @pytest.fixture @@ -86,8 +86,12 @@ async def test_exposed_areas( area_kitchen = area_registry.async_get_or_create("kitchen") area_bedroom = area_registry.async_get_or_create("bedroom") + entry = MockConfigEntry() + entry.add_to_hass(hass) kitchen_device = device_registry.async_get_or_create( - config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} + 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) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index f89af1dc201..37c8f9401bc 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1409,6 +1409,7 @@ async def test_turn_on_area( ) -> None: """Test turning on an area.""" entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -1480,6 +1481,7 @@ async def test_light_area_same_name( ) -> None: """Test turning on a light with the same name as an area.""" entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/tests/components/cpuspeed/snapshots/test_diagnostics.ambr b/tests/components/cpuspeed/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8efe36def6d --- /dev/null +++ b/tests/components/cpuspeed/snapshots/test_diagnostics.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'arch_string_raw': 'aargh', + 'brand_raw': 'Intel Ryzen 7', + 'hz_actual': list([ + 3200000001, + 0, + ]), + 'hz_advertised': list([ + 3600000001, + 0, + ]), + }) +# --- diff --git a/tests/components/cpuspeed/test_diagnostics.py b/tests/components/cpuspeed/test_diagnostics.py index 154f79f2f3e..2c91566216d 100644 --- a/tests/components/cpuspeed/test_diagnostics.py +++ b/tests/components/cpuspeed/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by the CPU Speed integration.""" from unittest.mock import patch +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,6 +14,7 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" info = { @@ -25,11 +28,7 @@ async def test_diagnostics( "homeassistant.components.cpuspeed.diagnostics.cpuinfo.get_cpu_info", return_value=info, ): - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "hz_actual": [3200000001, 0], - "arch_string_raw": "aargh", - "brand_raw": "Intel Ryzen 7", - "hz_advertised": [3600000001, 0], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 76956874e73..1ae795f2e95 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -3,11 +3,7 @@ from unittest import mock from unittest.mock import patch import homeassistant.components.datadog as datadog -from homeassistant.const import ( - EVENT_LOGBOOK_ENTRY, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import EVENT_LOGBOOK_ENTRY, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/datetime/test_init.py b/tests/components/datetime/test_init.py index 66390c8d90f..6f2e2db29a1 100644 --- a/tests/components/datetime/test_init.py +++ b/tests/components/datetime/test_init.py @@ -1,5 +1,5 @@ """The tests for the datetime component.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from zoneinfo import ZoneInfo import pytest @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_PLATFOR from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -DEFAULT_VALUE = datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc) +DEFAULT_VALUE = datetime(2020, 1, 1, 12, 0, 0, tzinfo=UTC) class MockDateTimeEntity(DateTimeEntity): diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..bbd96f1751c --- /dev/null +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'alarm_systems': dict({ + }), + 'config': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'host': '1.2.3.4', + 'port': 80, + }), + 'disabled_by': None, + 'domain': 'deconz', + 'entry_id': '1', + 'options': dict({ + 'master': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'deconz_config': dict({ + 'bridgeid': '**REDACTED**', + 'ipaddress': '1.2.3.4', + 'mac': '**REDACTED**', + 'modelid': 'deCONZ', + 'name': 'deCONZ mock gateway', + 'sw_version': '2.05.69', + 'uuid': '1234', + 'websocketport': 1234, + }), + 'deconz_ids': dict({ + }), + 'entities': dict({ + 'alarm_control_panel': list([ + ]), + 'binary_sensor': list([ + ]), + 'button': list([ + ]), + 'climate': list([ + ]), + 'cover': list([ + ]), + 'fan': list([ + ]), + 'light': list([ + ]), + 'lock': list([ + ]), + 'number': list([ + ]), + 'scene': list([ + ]), + 'select': list([ + ]), + 'sensor': list([ + ]), + 'siren': list([ + ]), + 'switch': list([ + ]), + }), + 'events': dict({ + }), + 'groups': dict({ + }), + 'lights': dict({ + }), + 'scenes': dict({ + }), + 'sensors': dict({ + }), + 'websocket_state': 'running', + }) +# --- diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index fe2ba8d4177..fe9d57f8a65 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -33,10 +33,7 @@ from homeassistant.setup import async_setup_component from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration -from tests.common import ( - async_get_device_automations, - async_mock_service, -) +from tests.common import async_get_device_automations, async_mock_service from tests.test_util.aiohttp import AiohttpClientMocker diff --git a/tests/components/deconz/test_diagnostics.py b/tests/components/deconz/test_diagnostics.py index 44b8bfd50dc..e7e470cdf81 100644 --- a/tests/components/deconz/test_diagnostics.py +++ b/tests/components/deconz/test_diagnostics.py @@ -1,12 +1,10 @@ """Test deCONZ diagnostics.""" from pydeconz.websocket import State +from syrupy import SnapshotAssertion -from homeassistant.components.deconz.const import CONF_MASTER_GATEWAY -from homeassistant.components.diagnostics import REDACTED -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .test_gateway import HOST, PORT, setup_deconz_integration +from .test_gateway import setup_deconz_integration from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker @@ -18,6 +16,7 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, mock_deconz_websocket, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -25,52 +24,7 @@ async def test_entry_diagnostics( await mock_deconz_websocket(state=State.RUNNING) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "config": { - "data": {CONF_API_KEY: REDACTED, CONF_HOST: HOST, CONF_PORT: PORT}, - "disabled_by": None, - "domain": "deconz", - "entry_id": "1", - "options": {CONF_MASTER_GATEWAY: True}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "title": "Mock Title", - "unique_id": REDACTED, - "version": 1, - }, - "deconz_config": { - "bridgeid": REDACTED, - "ipaddress": HOST, - "mac": REDACTED, - "modelid": "deCONZ", - "name": "deCONZ mock gateway", - "sw_version": "2.05.69", - "uuid": "1234", - "websocketport": 1234, - }, - "websocket_state": State.RUNNING.value, - "deconz_ids": {}, - "entities": { - str(Platform.ALARM_CONTROL_PANEL): [], - str(Platform.BINARY_SENSOR): [], - str(Platform.BUTTON): [], - str(Platform.CLIMATE): [], - str(Platform.COVER): [], - str(Platform.FAN): [], - str(Platform.LIGHT): [], - str(Platform.LOCK): [], - str(Platform.NUMBER): [], - str(Platform.SCENE): [], - str(Platform.SELECT): [], - str(Platform.SENSOR): [], - str(Platform.SIREN): [], - str(Platform.SWITCH): [], - }, - "events": {}, - "alarm_systems": {}, - "groups": {}, - "lights": {}, - "scenes": {}, - "sensors": {}, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 2d1d7a93afc..5ba00cabd9d 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -354,6 +354,7 @@ async def test_device_id(hass: HomeAssistant) -> None: entity_registry = er.async_get(hass) source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, identifiers={("sensor", "identifier_test")}, diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index d0f013299b1..74150af67ae 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -304,7 +304,7 @@ async def test_websocket_get_action_capabilities( return {"extra_fields": vol.Schema({vol.Optional("code"): str})} return {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_get_action_capabilities = _async_get_action_capabilities @@ -406,7 +406,7 @@ async def test_websocket_get_action_capabilities_bad_action( await async_setup_component(hass, "device_automation", {}) expected_capabilities = {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_get_action_capabilities = Mock( side_effect=InvalidDeviceAutomationConfig @@ -459,7 +459,7 @@ async def test_websocket_get_condition_capabilities( """List condition capabilities.""" return await toggle_entity.async_get_condition_capabilities(hass, config) - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_get_condition_capabilities = _async_get_condition_capabilities @@ -569,7 +569,7 @@ async def test_websocket_get_condition_capabilities_bad_condition( await async_setup_component(hass, "device_automation", {}) expected_capabilities = {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_get_condition_capabilities = Mock( side_effect=InvalidDeviceAutomationConfig @@ -747,7 +747,7 @@ async def test_websocket_get_trigger_capabilities( """List trigger capabilities.""" return await toggle_entity.async_get_trigger_capabilities(hass, config) - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_get_trigger_capabilities = _async_get_trigger_capabilities @@ -857,7 +857,7 @@ async def test_websocket_get_trigger_capabilities_bad_trigger( await async_setup_component(hass, "device_automation", {}) expected_capabilities = {} - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_get_trigger_capabilities = Mock( side_effect=InvalidDeviceAutomationConfig @@ -912,7 +912,7 @@ async def test_automation_with_device_action( ) -> None: """Test automation with a device action.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_call_action_from_config = AsyncMock() @@ -949,7 +949,7 @@ async def test_automation_with_dynamically_validated_action( ) -> None: """Test device automation with an action which is dynamically validated.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_validate_action_config = AsyncMock() @@ -1003,7 +1003,7 @@ async def test_automation_with_device_condition( ) -> None: """Test automation with a device condition.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_condition_from_config = Mock() @@ -1037,7 +1037,7 @@ async def test_automation_with_dynamically_validated_condition( ) -> None: """Test device automation with a condition which is dynamically validated.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_condition"] module.async_validate_condition_config = AsyncMock() @@ -1102,7 +1102,7 @@ async def test_automation_with_device_trigger( ) -> None: """Test automation with a device trigger.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_attach_trigger = AsyncMock() @@ -1136,7 +1136,7 @@ async def test_automation_with_dynamically_validated_trigger( ) -> None: """Test device automation with a trigger which is dynamically validated.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_attach_trigger = AsyncMock() module.async_validate_trigger_config = AsyncMock(wraps=lambda hass, config: config) @@ -1457,7 +1457,7 @@ async def test_automation_with_unknown_device( ) -> None: """Test device automation with a trigger with an unknown device.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_validate_trigger_config = AsyncMock() @@ -1492,12 +1492,14 @@ async def test_automation_with_device_wrong_domain( ) -> None: """Test device automation where the device doesn't have the right config entry.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_validate_trigger_config = AsyncMock() + source_config_entry = MockConfigEntry(domain="not_fake_integration") + source_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( - config_entry_id="not_fake_integration_config_entry", + config_entry_id=source_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert await async_setup_component( @@ -1532,7 +1534,7 @@ async def test_automation_with_device_component_not_loaded( ) -> None: """Test device automation where the device's config entry is not loaded.""" - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_trigger"] module.async_validate_trigger_config = AsyncMock() module.async_attach_trigger = AsyncMock() diff --git a/tests/components/device_tracker/test_entities.py b/tests/components/device_tracker/test_entities.py index 4ea5d280b5b..960f9c18b08 100644 --- a/tests/components/device_tracker/test_entities.py +++ b/tests/components/device_tracker/test_entities.py @@ -25,9 +25,11 @@ async def test_scanner_entity_device_tracker( ) -> None: """Test ScannerEntity based device tracker.""" # Make device tied to other integration so device tracker entities get enabled + other_config_entry = MockConfigEntry(domain="not_fake_integration") + other_config_entry.add_to_hass(hass) dr.async_get(hass).async_get_or_create( name="Device from other integration", - config_entry_id=MockConfigEntry().entry_id, + config_entry_id=other_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "ad:de:ef:be:ed:fe")}, ) diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index fe11a55eb85..bc2ef2d87b2 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -1,11 +1,13 @@ """Constants used for mocking data.""" from devolo_plc_api.device_api import ( + UPDATE_AVAILABLE, WIFI_BAND_2G, WIFI_BAND_5G, WIFI_VAP_MAIN_AP, ConnectedStationInfo, NeighborAPInfo, + UpdateFirmwareCheck, WifiGuestAccessGet, ) from devolo_plc_api.plcnet_api import LogicalNetwork @@ -79,6 +81,10 @@ DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( type="mock_type", ) +FIRMWARE_UPDATE_AVAILABLE = UpdateFirmwareCheck( + result=UPDATE_AVAILABLE, new_firmware_version="5.6.2_2023-01-15" +) + GUEST_WIFI = WifiGuestAccessGet( ssid="devolo-guest-930", key="HMANPGBA", @@ -86,6 +92,13 @@ GUEST_WIFI = WifiGuestAccessGet( remaining_duration=0, ) +GUEST_WIFI_CHANGED = WifiGuestAccessGet( + ssid="devolo-guest-930", + key="HMANPGAS", + enabled=False, + remaining_duration=0, +) + NEIGHBOR_ACCESS_POINTS = [ NeighborAPInfo( mac_address="AA:BB:CC:DD:EE:FF", diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 1cced53a520..80d1348cf0f 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -13,6 +13,7 @@ from zeroconf.asyncio import AsyncZeroconf from .const import ( CONNECTED_STATIONS, DISCOVERY_INFO, + FIRMWARE_UPDATE_AVAILABLE, GUEST_WIFI, IP, NEIGHBOR_ACCESS_POINTS, @@ -50,6 +51,9 @@ class MockDevice(Device): """Reset mock to starting point.""" self.async_disconnect = AsyncMock() self.device = DeviceApi(IP, None, DISCOVERY_INFO) + self.device.async_check_firmware_available = AsyncMock( + return_value=FIRMWARE_UPDATE_AVAILABLE + ) self.device.async_get_led_setting = AsyncMock(return_value=False) self.device.async_restart = AsyncMock(return_value=True) self.device.async_start_wps = AsyncMock(return_value=True) @@ -60,6 +64,7 @@ class MockDevice(Device): self.device.async_get_wifi_neighbor_access_points = AsyncMock( return_value=NEIGHBOR_ACCESS_POINTS ) + self.device.async_start_firmware_update = AsyncMock(return_value=True) self.plcnet = PlcNetApi(IP, None, DISCOVERY_INFO) self.plcnet.async_get_network_overview = AsyncMock(return_value=PLCNET) self.plcnet.async_identify_device_start = AsyncMock(return_value=True) diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr new file mode 100644 index 00000000000..b00f73ca116 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_guest_wifi_qr + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'image', + 'entity_category': , + 'entity_id': 'image.mock_title_guest_wifi_credentials_as_qr_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Guest Wifi credentials as QR code', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'image_guest_wifi', + 'unique_id': '1234567890_image_guest_wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_guest_wifi_qr.1 + b'\n\n' +# --- diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 7a6395c20f1..17d95fc51a3 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +14,6 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.const import 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 from . import configure_integration from .const import PLCNET_ATTACHED @@ -40,6 +40,7 @@ async def test_update_attached_to_router( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test state change of a attached_to_router binary sensor device.""" @@ -57,7 +58,8 @@ async def test_update_attached_to_router( mock_device.plcnet.async_get_network_overview = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -68,7 +70,8 @@ async def test_update_attached_to_router( mock_device.plcnet.async_get_network_overview = AsyncMock( return_value=PLCNET_ATTACHED ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py index 4b8521b5798..41820210dee 100644 --- a/tests/components/devolo_home_network/test_button.py +++ b/tests/components/devolo_home_network/test_button.py @@ -5,10 +5,7 @@ from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnav import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.button import ( - DOMAIN as PLATFORM, - SERVICE_PRESS, -) +from homeassistant.components.button import DOMAIN as PLATFORM, SERVICE_PRESS from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ATTR_ENTITY_ID diff --git a/tests/components/devolo_home_network/test_config_flow.py b/tests/components/devolo_home_network/test_config_flow.py index 91d7d6f39cf..9050181cc8f 100644 --- a/tests/components/devolo_home_network/test_config_flow.py +++ b/tests/components/devolo_home_network/test_config_flow.py @@ -7,7 +7,7 @@ from unittest.mock import patch from devolo_plc_api.exceptions.device import DeviceNotFound import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components.devolo_home_network import config_flow from homeassistant.components.devolo_home_network.const import ( DOMAIN, @@ -191,7 +191,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None: ) assert result["step_id"] == "reauth_confirm" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM with patch( "homeassistant.components.devolo_home_network.async_setup_entry", @@ -203,7 +203,7 @@ async def test_form_reauth(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 324f8b44041..8f58b1154de 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion from homeassistant.components.device_tracker import DOMAIN as PLATFORM @@ -12,7 +13,6 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.const import STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import configure_integration from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS @@ -28,6 +28,7 @@ async def test_device_tracker( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" @@ -37,13 +38,15 @@ async def test_device_tracker( entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Enable entity entity_registry.async_update_entity(state_key, disabled_by=None) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(state_key) == snapshot @@ -52,7 +55,8 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( return_value=NO_CONNECTED_STATIONS ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -63,7 +67,8 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py new file mode 100644 index 00000000000..b8fb491e1ec --- /dev/null +++ b/tests/components/devolo_home_network/test_image.py @@ -0,0 +1,96 @@ +"""Tests for the devolo Home Network images.""" +from http import HTTPStatus +from unittest.mock import AsyncMock + +from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.devolo_home_network.const import SHORT_UPDATE_INTERVAL +from homeassistant.components.image import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import configure_integration +from .const import GUEST_WIFI_CHANGED +from .mock import MockDevice + +from tests.common import async_fire_time_changed +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_device") +async def test_image_setup(hass: HomeAssistant) -> None: + """Test default setup of the image component.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + hass.states.get(f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code") + is not None + ) + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_guest_wifi_qr( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test showing a QR code of the guest wifi credentials.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.name == "Mock Title Guest Wifi credentials as QR code" + assert state.state == dt_util.utcnow().isoformat() + assert entity_registry.async_get(state_key) == snapshot + + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{state_key}") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == snapshot + + # Emulate device failure + mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() + freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + mock_device.device.async_get_wifi_guest_access = AsyncMock( + return_value=GUEST_WIFI_CHANGED + ) + freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == dt_util.utcnow().isoformat() + + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{state_key}") + assert resp.status == HTTPStatus.OK + assert await resp.read() != body + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 99b6053e1ba..3c207a1aaef 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -8,8 +8,10 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.button import DOMAIN as BUTTON from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.devolo_home_network.const import DOMAIN +from homeassistant.components.image import DOMAIN as IMAGE from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.components.update import DOMAIN as UPDATE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -84,9 +86,15 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: @pytest.mark.parametrize( ("device", "expected_platforms"), [ - ["mock_device", (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, SENSOR, SWITCH)], - ["mock_repeater_device", (BUTTON, DEVICE_TRACKER, SENSOR, SWITCH)], - ["mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH)], + [ + "mock_device", + (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), + ], + [ + "mock_repeater_device", + (BUTTON, DEVICE_TRACKER, IMAGE, SENSOR, SWITCH, UPDATE), + ], + ["mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH, UPDATE)], ], ) async def test_platforms( diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index dc7842e5fbd..230457f5617 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +15,6 @@ from homeassistant.components.sensor import DOMAIN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -62,6 +62,7 @@ async def test_sensor( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, name: str, get_method: str, @@ -80,7 +81,8 @@ async def test_sensor( # Emulate device failure setattr(mock_device.device, get_method, AsyncMock(side_effect=DeviceUnavailable)) setattr(mock_device.plcnet, get_method, AsyncMock(side_effect=DeviceUnavailable)) - async_fire_time_changed(hass, dt_util.utcnow() + interval) + freezer.tick(interval) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -89,7 +91,8 @@ async def test_sensor( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt_util.utcnow() + interval) + freezer.tick(interval) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 00c06a6acc1..c77a77e87de 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch from devolo_plc_api.device_api import WifiGuestAccessGet from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -24,7 +25,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN -from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -75,6 +75,7 @@ async def test_update_enable_guest_wifi( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test state change of a enable_guest_wifi switch device.""" @@ -92,7 +93,8 @@ async def test_update_enable_guest_wifi( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( enabled=True ) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -116,9 +118,8 @@ async def test_update_enable_guest_wifi( assert state.state == STATE_OFF turn_off.assert_called_once_with(False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Switch on @@ -138,9 +139,8 @@ async def test_update_enable_guest_wifi( assert state.state == STATE_ON turn_on.assert_called_once_with(True) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Device unavailable @@ -164,6 +164,7 @@ async def test_update_enable_leds( hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test state change of a enable_leds switch device.""" @@ -179,7 +180,8 @@ async def test_update_enable_leds( # Emulate state change mock_device.device.async_get_led_setting.return_value = True - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -201,9 +203,8 @@ async def test_update_enable_leds( assert state.state == STATE_OFF turn_off.assert_called_once_with(False) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Switch on @@ -221,9 +222,8 @@ async def test_update_enable_leds( assert state.state == STATE_ON turn_on.assert_called_once_with(True) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) - ) + freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) + async_fire_time_changed(hass) await hass.async_block_till_done() # Device unavailable @@ -253,6 +253,7 @@ async def test_update_enable_leds( async def test_device_failure( hass: HomeAssistant, mock_device: MockDevice, + freezer: FrozenDateTimeFactory, name: str, get_method: str, update_interval: timedelta, @@ -270,7 +271,8 @@ async def test_device_failure( api = getattr(mock_device.device, get_method) api.side_effect = DeviceUnavailable - async_fire_time_changed(hass, dt_util.utcnow() + update_interval) + freezer.tick(update_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py new file mode 100644 index 00000000000..97d313d9273 --- /dev/null +++ b/tests/components/devolo_home_network/test_update.py @@ -0,0 +1,173 @@ +"""Tests for the devolo Home Network update.""" +from devolo_plc_api.device_api import UPDATE_NOT_AVAILABLE, UpdateFirmwareCheck +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.devolo_home_network.const import ( + DOMAIN, + LONG_UPDATE_INTERVAL, +) +from homeassistant.components.update import ( + DOMAIN as PLATFORM, + SERVICE_INSTALL, + UpdateDeviceClass, +) +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from . import configure_integration +from .const import FIRMWARE_UPDATE_AVAILABLE +from .mock import MockDevice + +from tests.common import async_fire_time_changed + + +@pytest.mark.usefixtures("mock_device") +async def test_update_setup(hass: HomeAssistant) -> None: + """Test default setup of the update component.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(f"{PLATFORM}.{device_name}_firmware") is not None + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_firmware( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test updating a device.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON + assert state.attributes["device_class"] == UpdateDeviceClass.FIRMWARE + assert state.attributes["installed_version"] == mock_device.firmware_version + assert ( + state.attributes["latest_version"] + == FIRMWARE_UPDATE_AVAILABLE.new_firmware_version.split("_")[0] + ) + + assert entity_registry.async_get(state_key).entity_category == EntityCategory.CONFIG + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + assert mock_device.device.async_start_firmware_update.call_count == 1 + + # Emulate state change + mock_device.device.async_check_firmware_available.return_value = ( + UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) + ) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_OFF + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_device_failure_check( + hass: HomeAssistant, + mock_device: MockDevice, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device failure during check.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + + mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_device_failure_update( + hass: HomeAssistant, + mock_device: MockDevice, +) -> None: + """Test device failure when starting update.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_device.device.async_start_firmware_update.side_effect = DeviceUnavailable + + # Emulate update start + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test updating unautherized triggers the reauth flow.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_device.device.async_start_firmware_update.side_effect = DevicePasswordProtected + + with pytest.raises(HomeAssistantError): + assert await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == entry.entry_id + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dexcom/test_sensor.py b/tests/components/dexcom/test_sensor.py index 8e1974a3533..a211f0606f3 100644 --- a/tests/components/dexcom/test_sensor.py +++ b/tests/components/dexcom/test_sensor.py @@ -19,13 +19,9 @@ async def test_sensors(hass: HomeAssistant) -> None: """Test we get sensor data.""" await init_integration(hass) - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == str(GLUCOSE_READING.value) - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description @@ -37,16 +33,12 @@ async def test_sensors_unknown(hass: HomeAssistant) -> None: "homeassistant.components.dexcom.Dexcom.get_current_glucose_reading", return_value=None, ): - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_value") - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_trend") + await async_update_entity(hass, "sensor.test_username_glucose_value") + await async_update_entity(hass, "sensor.test_username_glucose_trend") - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == STATE_UNKNOWN - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == STATE_UNKNOWN @@ -58,16 +50,12 @@ async def test_sensors_update_failed(hass: HomeAssistant) -> None: "homeassistant.components.dexcom.Dexcom.get_current_glucose_reading", side_effect=SessionError, ): - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_value") - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_trend") + await async_update_entity(hass, "sensor.test_username_glucose_value") + await async_update_entity(hass, "sensor.test_username_glucose_trend") - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == STATE_UNAVAILABLE - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == STATE_UNAVAILABLE @@ -75,13 +63,9 @@ async def test_sensors_options_changed(hass: HomeAssistant) -> None: """Test we handle sensor unavailable.""" entry = await init_integration(hass) - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == str(GLUCOSE_READING.value) - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description with patch( @@ -99,11 +83,7 @@ async def test_sensors_options_changed(hass: HomeAssistant) -> None: assert entry.options == {CONF_UNIT_OF_MEASUREMENT: MMOL_L} - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == str(GLUCOSE_READING.mmol_l) - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 0754febfc76..076138080cc 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -4,7 +4,7 @@ import threading from unittest.mock import MagicMock, patch import pytest -from scapy import arch # pylint: disable=unused-import # noqa: F401 +from scapy import arch # noqa: F401 from scapy.error import Scapy_Exception from scapy.layers.dhcp import DHCP from scapy.layers.l2 import Ether diff --git a/tests/components/discovergy/snapshots/test_diagnostics.ambr b/tests/components/discovergy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d02f57c7540 --- /dev/null +++ b/tests/components/discovergy/snapshots/test_diagnostics.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'meters': list([ + dict({ + 'additional': dict({ + 'administration_number': '**REDACTED**', + 'current_scaling_factor': 1, + 'first_measurement_time': 1517569090926, + 'internal_meters': 1, + 'last_measurement_time': 1678430543742, + 'manufacturer_id': 'TST', + 'printed_full_serial_number': '**REDACTED**', + 'scaling_factor': 1, + 'voltage_scaling_factor': 1, + }), + 'full_serial_number': '**REDACTED**', + 'load_profile_type': 'SLP', + 'location': '**REDACTED**', + 'measurement_type': 'ELECTRICITY', + 'meter_id': 'f8d610b7a8cc4e73939fa33b990ded54', + 'serial_number': '**REDACTED**', + 'type': 'TST', + }), + ]), + 'readings': dict({ + 'f8d610b7a8cc4e73939fa33b990ded54': dict({ + 'time': '2023-03-10T07:32:06.702000', + 'values': dict({ + 'energy': 119348699715000.0, + 'energy1': 2254180000.0, + 'energy2': 119346445534000.0, + 'energyOut': 55048723044000.0, + 'energyOut1': 0.0, + 'energyOut2': 0.0, + 'power': 531750.0, + 'power1': 142680.0, + 'power2': 138010.0, + 'power3': 251060.0, + 'voltage1': 239800.0, + 'voltage2': 239700.0, + 'voltage3': 239000.0, + }), + }), + }), + }) +# --- diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index b9da2bb7e6f..d7565e3f0c4 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -1,7 +1,8 @@ """Test Discovergy diagnostics.""" from unittest.mock import patch -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -14,6 +15,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS), patch( @@ -26,60 +28,4 @@ async def test_entry_diagnostics( hass, hass_client, mock_config_entry ) - assert result["entry"] == { - "entry_id": mock_config_entry.entry_id, - "version": 1, - "domain": "discovergy", - "title": REDACTED, - "data": {"email": REDACTED, "password": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - } - - assert result["meters"] == [ - { - "additional": { - "administration_number": REDACTED, - "current_scaling_factor": 1, - "first_measurement_time": 1517569090926, - "internal_meters": 1, - "last_measurement_time": 1678430543742, - "manufacturer_id": "TST", - "printed_full_serial_number": REDACTED, - "scaling_factor": 1, - "voltage_scaling_factor": 1, - }, - "full_serial_number": REDACTED, - "load_profile_type": "SLP", - "location": REDACTED, - "measurement_type": "ELECTRICITY", - "meter_id": "f8d610b7a8cc4e73939fa33b990ded54", - "serial_number": REDACTED, - "type": "TST", - } - ] - - assert result["readings"] == { - "f8d610b7a8cc4e73939fa33b990ded54": { - "time": "2023-03-10T07:32:06.702000", - "values": { - "energy": 119348699715000.0, - "energy1": 2254180000.0, - "energy2": 119346445534000.0, - "energyOut": 55048723044000.0, - "energyOut1": 0.0, - "energyOut2": 0.0, - "power": 531750.0, - "power1": 142680.0, - "power2": 138010.0, - "power3": 251060.0, - "voltage1": 239800.0, - "voltage2": 239700.0, - "voltage3": 239000.0, - }, - } - } + assert result == snapshot diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index 43e60638ba9..be49a6ca257 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -10,7 +10,7 @@ from async_upnp_client.client import UpnpDevice from async_upnp_client.exceptions import UpnpError import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.dlna_dmr.const import ( CONF_BROWSE_UNFILTERED, @@ -21,6 +21,7 @@ from homeassistant.components.dlna_dmr.const import ( ) from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from .conftest import ( MOCK_DEVICE_HOST_ADDR, @@ -101,7 +102,7 @@ async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "manual" @@ -109,7 +110,7 @@ async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -136,7 +137,7 @@ async def test_user_flow_discovered_manual( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] is None assert result["step_id"] == "user" @@ -144,7 +145,7 @@ async def test_user_flow_discovered_manual( result["flow_id"], user_input={} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "manual" @@ -152,7 +153,7 @@ async def test_user_flow_discovered_manual( result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -177,7 +178,7 @@ async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] is None assert result["step_id"] == "user" @@ -185,7 +186,7 @@ async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) result["flow_id"], user_input={CONF_HOST: MOCK_DEVICE_NAME} ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -208,7 +209,7 @@ async def test_user_flow_uncontactable( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "manual" @@ -216,7 +217,7 @@ async def test_user_flow_uncontactable( result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} assert result["step_id"] == "manual" @@ -241,7 +242,7 @@ async def test_user_flow_embedded_st( result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "manual" @@ -249,7 +250,7 @@ async def test_user_flow_embedded_st( result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -272,7 +273,7 @@ async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) - result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} assert result["step_id"] == "manual" @@ -280,7 +281,7 @@ async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) - result["flow_id"], user_input={CONF_URL: MOCK_DEVICE_LOCATION} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": "not_dmr"} assert result["step_id"] == "manual" @@ -295,7 +296,7 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=MOCK_DISCOVERY, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( @@ -303,7 +304,7 @@ async def test_ssdp_flow_success(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -327,7 +328,7 @@ async def test_ssdp_flow_unavailable( context={"source": config_entries.SOURCE_SSDP}, data=MOCK_DISCOVERY, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" domain_data_mock.upnp_factory.async_create_device.side_effect = UpnpError @@ -337,7 +338,7 @@ async def test_ssdp_flow_unavailable( ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -368,7 +369,7 @@ async def test_ssdp_flow_existing( }, ), ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION @@ -388,7 +389,7 @@ async def test_ssdp_flow_duplicate_location( context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" assert config_entry_mock.data[CONF_URL] == MOCK_DEVICE_LOCATION @@ -414,7 +415,7 @@ async def test_ssdp_duplicate_mac_ignored_entry( context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -437,7 +438,7 @@ async def test_ssdp_duplicate_mac_configured_entry( context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -453,7 +454,7 @@ async def test_ssdp_add_mac( context={"source": config_entries.SOURCE_SSDP}, data=MOCK_DISCOVERY, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" await hass.async_block_till_done() @@ -474,7 +475,7 @@ async def test_ssdp_dont_remove_mac( context={"source": config_entries.SOURCE_SSDP}, data=MOCK_DISCOVERY, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" await hass.async_block_till_done() @@ -502,7 +503,7 @@ async def test_ssdp_flow_upnp_udn( }, ), ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" assert config_entry_mock.data[CONF_URL] == NEW_DEVICE_LOCATION @@ -518,7 +519,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "not_dmr" # Service list does not contain services @@ -530,7 +531,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "not_dmr" # AVTransport service is missing @@ -546,7 +547,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=discovery ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "not_dmr" @@ -568,7 +569,7 @@ async def test_ssdp_single_service(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "not_dmr" @@ -582,7 +583,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "alternative_integration" discovery = dataclasses.replace(MOCK_DISCOVERY) @@ -595,7 +596,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "alternative_integration" for manufacturer, model in [ @@ -613,7 +614,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "alternative_integration" @@ -635,7 +636,7 @@ async def test_ignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -659,7 +660,7 @@ async def test_ignore_flow_no_ssdp( ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: None, @@ -680,7 +681,7 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME # Device was found via SSDP, matching the 2nd device type tried @@ -698,7 +699,7 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No context={"source": config_entries.SOURCE_UNIGNORE}, data={"unique_id": MOCK_DEVICE_UDN}, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( @@ -706,7 +707,7 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME assert result["data"] == { CONF_URL: MOCK_DEVICE_LOCATION, @@ -733,7 +734,7 @@ async def test_unignore_flow_offline( ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == MOCK_DEVICE_NAME # Device is not in the SSDP discoveries (perhaps HA restarted between ignore and unignore) @@ -745,7 +746,7 @@ async def test_unignore_flow_offline( context={"source": config_entries.SOURCE_UNIGNORE}, data={"unique_id": MOCK_DEVICE_UDN}, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "discovery_error" @@ -759,7 +760,7 @@ async def test_get_mac_address_ipv4( context={"source": config_entries.SOURCE_SSDP}, data=MOCK_DISCOVERY, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" mock_get_mac_address.assert_called_once_with(ip=MOCK_DEVICE_HOST_ADDR) @@ -783,7 +784,7 @@ async def test_get_mac_address_ipv6( context={"source": config_entries.SOURCE_SSDP}, data=discovery, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" # The scope must be removed for get_mac_address to work correctly @@ -824,7 +825,7 @@ async def test_options_flow( config_entry_mock.add_to_hass(hass) result = await hass.config_entries.options.async_init(config_entry_mock.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" assert result["errors"] == {} @@ -838,7 +839,7 @@ async def test_options_flow( }, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" assert result["errors"] == {"base": "invalid_url"} @@ -853,7 +854,7 @@ async def test_options_flow( }, ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_LISTEN_PORT: 2222, CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", diff --git a/tests/components/dremel_3d_printer/test_config_flow.py b/tests/components/dremel_3d_printer/test_config_flow.py index 8161662a14a..e968e0af491 100644 --- a/tests/components/dremel_3d_printer/test_config_flow.py +++ b/tests/components/dremel_3d_printer/test_config_flow.py @@ -3,11 +3,11 @@ from unittest.mock import patch from requests.exceptions import ConnectTimeout -from homeassistant import data_entry_flow from homeassistant.components.dremel_3d_printer.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from .conftest import CONF_DATA, patch_async_setup_entry @@ -22,7 +22,7 @@ async def test_full_user_flow_implementation(hass: HomeAssistant, connection) -> DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch_async_setup_entry(): @@ -30,7 +30,7 @@ async def test_full_user_flow_implementation(hass: HomeAssistant, connection) -> result["flow_id"], user_input=CONF_DATA ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "DREMEL 3D45" assert result["data"] == CONF_DATA @@ -42,7 +42,7 @@ async def test_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -53,7 +53,7 @@ async def test_cannot_connect(hass: HomeAssistant, connection) -> None: DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} @@ -62,7 +62,7 @@ async def test_cannot_connect(hass: HomeAssistant, connection) -> None: result["flow_id"], user_input=CONF_DATA ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == CONF_DATA @@ -73,7 +73,7 @@ async def test_unknown_error(hass: HomeAssistant, connection) -> None: DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} @@ -82,6 +82,6 @@ async def test_unknown_error(hass: HomeAssistant, connection) -> None: result["flow_id"], user_input=CONF_DATA ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "DREMEL 3D45" assert result["data"] == CONF_DATA diff --git a/tests/components/easyenergy/snapshots/test_diagnostics.ambr b/tests/components/easyenergy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..805846832aa --- /dev/null +++ b/tests/components/easyenergy/snapshots/test_diagnostics.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'energy_return': dict({ + 'average_price': 0.14599, + 'current_hour_price': 0.18629, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.20394, + 'min_price': 0.10172, + 'next_hour_price': 0.20394, + 'percentage_of_max': 91.35, + }), + 'energy_usage': dict({ + 'average_price': 0.17665, + 'current_hour_price': 0.22541, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.24677, + 'min_price': 0.12308, + 'next_hour_price': 0.24677, + 'percentage_of_max': 91.34, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': 0.7253, + 'next_hour_price': 0.7253, + }), + }) +# --- +# name: test_diagnostics_no_gas_today + dict({ + 'energy_return': dict({ + 'average_price': 0.14599, + 'current_hour_price': 0.18629, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.20394, + 'min_price': 0.10172, + 'next_hour_price': 0.20394, + 'percentage_of_max': 91.35, + }), + 'energy_usage': dict({ + 'average_price': 0.17665, + 'current_hour_price': 0.22541, + 'highest_price_time': '2023-01-19T16:00:00+00:00', + 'lowest_price_time': '2023-01-19T02:00:00+00:00', + 'max_price': 0.24677, + 'min_price': 0.12308, + 'next_hour_price': 0.24677, + 'percentage_of_max': 91.34, + }), + 'entry': dict({ + 'title': 'energy', + }), + 'gas': dict({ + 'current_hour_price': None, + 'next_hour_price': None, + }), + }) +# --- diff --git a/tests/components/easyenergy/test_diagnostics.py b/tests/components/easyenergy/test_diagnostics.py index 336f363e6a1..f76821cf265 100644 --- a/tests/components/easyenergy/test_diagnostics.py +++ b/tests/components/easyenergy/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock from easyenergy import EasyEnergyNoDataError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY from homeassistant.const import ATTR_ENTITY_ID @@ -19,39 +20,13 @@ 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 - ) == { - "entry": { - "title": "energy", - }, - "energy_usage": { - "current_hour_price": 0.22541, - "next_hour_price": 0.24677, - "average_price": 0.17665, - "max_price": 0.24677, - "min_price": 0.12308, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.34, - }, - "energy_return": { - "current_hour_price": 0.18629, - "next_hour_price": 0.20394, - "average_price": 0.14599, - "max_price": 0.20394, - "min_price": 0.10172, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.35, - }, - "gas": { - "current_hour_price": 0.7253, - "next_hour_price": 0.7253, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) @pytest.mark.freeze_time("2023-01-19 15:00:00") @@ -60,6 +35,7 @@ async def test_diagnostics_no_gas_today( hass_client: ClientSessionGenerator, mock_easyenergy: MagicMock, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics, no gas sensors available.""" await async_setup_component(hass, "homeassistant", {}) @@ -73,34 +49,7 @@ async def test_diagnostics_no_gas_today( ) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": { - "title": "energy", - }, - "energy_usage": { - "current_hour_price": 0.22541, - "next_hour_price": 0.24677, - "average_price": 0.17665, - "max_price": 0.24677, - "min_price": 0.12308, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.34, - }, - "energy_return": { - "current_hour_price": 0.18629, - "next_hour_price": 0.20394, - "average_price": 0.14599, - "max_price": 0.20394, - "min_price": 0.10172, - "highest_price_time": "2023-01-19T16:00:00+00:00", - "lowest_price_time": "2023-01-19T02:00:00+00:00", - "percentage_of_max": 91.35, - }, - "gas": { - "current_hour_price": None, - "next_hour_price": None, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/ecobee/test_config_flow.py b/tests/components/ecobee/test_config_flow.py index a4185313f5f..7d79a10e912 100644 --- a/tests/components/ecobee/test_config_flow.py +++ b/tests/components/ecobee/test_config_flow.py @@ -14,7 +14,7 @@ from homeassistant.components.ecobee.const import ( from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry async def test_abort_if_already_setup(hass: HomeAssistant) -> None: @@ -175,9 +175,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_invalid_data( with patch( "homeassistant.components.ecobee.config_flow.load_json_object", return_value=MOCK_ECOBEE_CONF, - ), patch.object( - flow, "async_step_user", return_value=mock_coro() - ) as mock_async_step_user: + ), patch.object(flow, "async_step_user") as mock_async_step_user: await flow.async_step_import(import_data=None) mock_async_step_user.assert_called_once_with( @@ -201,7 +199,7 @@ async def test_import_flow_triggered_with_ecobee_conf_and_valid_data_and_stale_t ), patch( "homeassistant.components.ecobee.config_flow.Ecobee" ) as mock_ecobee, patch.object( - flow, "async_step_user", return_value=mock_coro() + flow, "async_step_user" ) as mock_async_step_user: mock_ecobee = mock_ecobee.return_value mock_ecobee.refresh_tokens.return_value = False diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index acfb11ced0a..24acde0709a 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -314,7 +314,7 @@ async def test_discover_lights(hass: HomeAssistant, hue_client) -> None: await hass.async_block_till_done() result_json = await async_get_lights(hue_client) - assert "1" not in result_json.keys() + assert "1" not in result_json devices = {val["uniqueid"] for val in result_json.values()} assert "00:2f:d2:31:ce:c5:55:cc-ee" not in devices # light.ceiling_lights @@ -1130,7 +1130,6 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 -# pylint: disable=invalid-name async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client) -> None: """Test the form with urlencoded content.""" entity_number = ENTITY_NUMBERS_BY_ID["light.ceiling_lights"] @@ -1215,7 +1214,6 @@ async def test_get_empty_groups_state(hue_client) -> None: assert result_json == {} -# pylint: disable=invalid-name async def perform_put_test_on_ceiling_lights( hass_hue, hue_client, content_type=CONTENT_TYPE_JSON ): diff --git a/tests/components/energyzero/snapshots/test_diagnostics.ambr b/tests/components/energyzero/snapshots/test_diagnostics.ambr index 488e01e8d18..90c11ecfc6f 100644 --- a/tests/components/energyzero/snapshots/test_diagnostics.ambr +++ b/tests/components/energyzero/snapshots/test_diagnostics.ambr @@ -5,6 +5,7 @@ 'average_price': 0.37, 'current_hour_price': 0.49, 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'hours_priced_equal_or_lower': 23, 'lowest_price_time': '2022-12-07T02:00:00+00:00', 'max_price': 0.55, 'min_price': 0.26, @@ -26,6 +27,7 @@ 'average_price': 0.37, 'current_hour_price': 0.49, 'highest_price_time': '2022-12-07T16:00:00+00:00', + 'hours_priced_equal_or_lower': 23, 'lowest_price_time': '2022-12-07T02:00:00+00:00', 'max_price': 0.55, 'min_price': 0.26, diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 619813c52c1..e51aef980d1 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -651,6 +651,71 @@ 'via_device_id': None, }) # --- +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by EnergyZero', + 'friendly_name': 'Energy market price Hours priced equal or lower than current - today', + 'icon': 'mdi:clock', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energyzero_today_energy_hours_priced_equal_or_lower', + 'last_changed': , + 'last_updated': , + 'state': '23', + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].1 + 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.energyzero_today_energy_hours_priced_equal_or_lower', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:clock', + 'original_name': 'Hours priced equal or lower than current - today', + 'platform': 'energyzero', + 'supported_features': 0, + 'translation_key': 'hours_priced_equal_or_lower', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.energyzero_today_energy_hours_priced_equal_or_lower-today_energy_hours_priced_equal_or_lower-today_energy].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': 'EnergyZero', + 'model': None, + 'name': 'Energy market price', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_sensor[sensor.energyzero_today_energy_max_price-today_energy_max_price-today_energy] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 466e754df27..6c7eec9d5d8 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -41,6 +41,11 @@ pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] "today_energy_highest_price_time", "today_energy", ), + ( + "sensor.energyzero_today_energy_hours_priced_equal_or_lower", + "today_energy_hours_priced_equal_or_lower", + "today_energy", + ), ( "sensor.energyzero_today_gas_current_hour_price", "today_gas_current_hour_price", diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 93a76bdd510..41cbb239129 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -1,7 +1,14 @@ """Define test fixtures for Enphase Envoy.""" -import json from unittest.mock import AsyncMock, Mock, patch +from pyenphase import ( + Envoy, + EnvoyData, + EnvoyInverter, + EnvoySystemConsumption, + EnvoySystemProduction, + EnvoyTokenAuth, +) import pytest from homeassistant.components.enphase_envoy import DOMAIN @@ -9,7 +16,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry @pytest.fixture(name="config_entry") @@ -17,6 +24,7 @@ def config_entry_fixture(hass: HomeAssistant, config, serial_number): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", title=f"Envoy {serial_number}" if serial_number else "Envoy", unique_id=serial_number, data=config, @@ -36,66 +44,50 @@ def config_fixture(): } -@pytest.fixture(name="gateway_data", scope="package") -def gateway_data_fixture(): - """Define a fixture to return gateway data.""" - return json.loads(load_fixture("data.json", "enphase_envoy")) - - -@pytest.fixture(name="inverters_production_data", scope="package") -def inverters_production_data_fixture(): - """Define a fixture to return inverter production data.""" - return json.loads(load_fixture("inverters_production.json", "enphase_envoy")) - - -@pytest.fixture(name="mock_envoy_reader") -def mock_envoy_reader_fixture( - gateway_data, - mock_get_data, - mock_get_full_serial_number, - mock_inverters_production, - serial_number, -): - """Define a mocked EnvoyReader fixture.""" - mock_envoy_reader = Mock( - getData=mock_get_data, - get_full_serial_number=mock_get_full_serial_number, - inverters_production=mock_inverters_production, +@pytest.fixture(name="mock_envoy") +def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): + """Define a mocked Envoy fixture.""" + mock_envoy = Mock(spec=Envoy) + mock_envoy.serial_number = serial_number + mock_envoy.authenticate = mock_authenticate + mock_envoy.setup = mock_setup + mock_envoy.auth = mock_auth + mock_envoy.data = EnvoyData( + system_consumption=EnvoySystemConsumption( + watt_hours_last_7_days=1234, + watt_hours_lifetime=1234, + watt_hours_today=1234, + watts_now=1234, + ), + system_production=EnvoySystemProduction( + watt_hours_last_7_days=1234, + watt_hours_lifetime=1234, + watt_hours_today=1234, + watts_now=1234, + ), + inverters={ + "1": EnvoyInverter( + serial_number="1", + last_report_date=1, + last_report_watts=1, + max_report_watts=1, + ) + }, + raw={"varies_by": "firmware_version"}, ) - - for key, value in gateway_data.items(): - setattr(mock_envoy_reader, key, AsyncMock(return_value=value)) - - return mock_envoy_reader - - -@pytest.fixture(name="mock_get_full_serial_number") -def mock_get_full_serial_number_fixture(serial_number): - """Define a mocked EnvoyReader.get_full_serial_number fixture.""" - return AsyncMock(return_value=serial_number) - - -@pytest.fixture(name="mock_get_data") -def mock_get_data_fixture(): - """Define a mocked EnvoyReader.getData fixture.""" - return AsyncMock() - - -@pytest.fixture(name="mock_inverters_production") -def mock_inverters_production_fixture(inverters_production_data): - """Define a mocked EnvoyReader.inverters_production fixture.""" - return AsyncMock(return_value=inverters_production_data) + mock_envoy.update = AsyncMock(return_value=mock_envoy.data) + return mock_envoy @pytest.fixture(name="setup_enphase_envoy") -async def setup_enphase_envoy_fixture(hass, config, mock_envoy_reader): +async def setup_enphase_envoy_fixture(hass, config, mock_envoy): """Define a fixture to set up Enphase Envoy.""" with patch( - "homeassistant.components.enphase_envoy.config_flow.EnvoyReader", - return_value=mock_envoy_reader, + "homeassistant.components.enphase_envoy.config_flow.Envoy", + return_value=mock_envoy, ), patch( - "homeassistant.components.enphase_envoy.EnvoyReader", - return_value=mock_envoy_reader, + "homeassistant.components.enphase_envoy.Envoy", + return_value=mock_envoy, ), patch( "homeassistant.components.enphase_envoy.PLATFORMS", [] ): @@ -104,6 +96,24 @@ async def setup_enphase_envoy_fixture(hass, config, mock_envoy_reader): yield +@pytest.fixture(name="mock_authenticate") +def mock_authenticate(): + """Define a mocked Envoy.authenticate fixture.""" + return AsyncMock() + + +@pytest.fixture(name="mock_auth") +def mock_auth(serial_number): + """Define a mocked EnvoyAuth fixture.""" + return EnvoyTokenAuth("127.0.0.1", token="abc", envoy_serial=serial_number) + + +@pytest.fixture(name="mock_setup") +def mock_setup(): + """Define a mocked Envoy.setup fixture.""" + return AsyncMock() + + @pytest.fixture(name="serial_number") def serial_number_fixture(): """Define a serial number fixture.""" diff --git a/tests/components/enphase_envoy/fixtures/__init__.py b/tests/components/enphase_envoy/fixtures/__init__.py deleted file mode 100644 index b3ef7db17a3..00000000000 --- a/tests/components/enphase_envoy/fixtures/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Define data fixtures for Enphase Envoy.""" diff --git a/tests/components/enphase_envoy/fixtures/data.json b/tests/components/enphase_envoy/fixtures/data.json deleted file mode 100644 index d6868a6dbf7..00000000000 --- a/tests/components/enphase_envoy/fixtures/data.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "production": 1840, - "daily_production": 28223, - "seven_days_production": 174482, - "lifetime_production": 5924391, - "consumption": 1840, - "daily_consumption": 5923857, - "seven_days_consumption": 5923857, - "lifetime_consumption": 5923857 -} diff --git a/tests/components/enphase_envoy/fixtures/inverters_production.json b/tests/components/enphase_envoy/fixtures/inverters_production.json deleted file mode 100644 index 14891f2d278..00000000000 --- a/tests/components/enphase_envoy/fixtures/inverters_production.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "202140024014": [136, "2022-10-08 16:43:36"], - "202140023294": [163, "2022-10-08 16:43:41"], - "202140013819": [130, "2022-10-08 16:43:31"], - "202140023794": [139, "2022-10-08 16:43:38"], - "202140023381": [130, "2022-10-08 16:43:47"], - "202140024176": [54, "2022-10-08 16:43:59"], - "202140003284": [132, "2022-10-08 16:43:55"], - "202140019854": [129, "2022-10-08 16:43:58"], - "202140020743": [131, "2022-10-08 16:43:49"], - "202140023531": [28, "2022-10-08 16:43:53"], - "202140024241": [164, "2022-10-08 16:43:33"], - "202140022963": [164, "2022-10-08 16:43:41"], - "202140023149": [118, "2022-10-08 16:43:47"], - "202140024828": [129, "2022-10-08 16:43:36"], - "202140023269": [133, "2022-10-08 16:43:43"], - "202140024157": [112, "2022-10-08 16:43:52"] -} diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..098fc4ee37e --- /dev/null +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'varies_by': 'firmware_version', + }), + 'entry': dict({ + 'data': dict({ + 'host': '1.1.1.1', + 'name': '**REDACTED**', + 'password': '**REDACTED**', + 'token': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'enphase_envoy', + 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index fac5b01c60e..a4481f4ed51 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Enphase Envoy config flow.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock -import httpx +from pyenphase import EnvoyAuthenticationError, EnvoyError import pytest from homeassistant import config_entries @@ -65,16 +65,7 @@ async def test_user_no_serial_number( } -@pytest.mark.parametrize( - "mock_get_full_serial_number", - [ - AsyncMock( - side_effect=httpx.HTTPStatusError( - "any", request=MagicMock(), response=MagicMock() - ) - ) - ], -) +@pytest.mark.parametrize("serial_number", [None]) async def test_user_fetching_serial_fails( hass: HomeAssistant, setup_enphase_envoy ) -> None: @@ -104,13 +95,9 @@ async def test_user_fetching_serial_fails( @pytest.mark.parametrize( - "mock_get_data", + "mock_authenticate", [ - AsyncMock( - side_effect=httpx.HTTPStatusError( - "any", request=MagicMock(), response=MagicMock() - ) - ) + AsyncMock(side_effect=EnvoyAuthenticationError("test")), ], ) async def test_form_invalid_auth(hass: HomeAssistant, setup_enphase_envoy) -> None: @@ -131,7 +118,8 @@ async def test_form_invalid_auth(hass: HomeAssistant, setup_enphase_envoy) -> No @pytest.mark.parametrize( - "mock_get_data", [AsyncMock(side_effect=httpx.HTTPError("any"))] + "mock_setup", + [AsyncMock(side_effect=EnvoyError)], ) async def test_form_cannot_connect(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle cannot connect error.""" @@ -150,7 +138,10 @@ async def test_form_cannot_connect(hass: HomeAssistant, setup_enphase_envoy) -> assert result2["errors"] == {"base": "cannot_connect"} -@pytest.mark.parametrize("mock_get_data", [AsyncMock(side_effect=ValueError)]) +@pytest.mark.parametrize( + "mock_setup", + [AsyncMock(side_effect=ValueError)], +) async def test_form_unknown_error(hass: HomeAssistant, setup_enphase_envoy) -> None: """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( @@ -168,7 +159,17 @@ async def test_form_unknown_error(hass: HomeAssistant, setup_enphase_envoy) -> N assert result2["errors"] == {"base": "unknown"} -async def test_zeroconf(hass: HomeAssistant, setup_enphase_envoy) -> None: +def _get_schema_default(schema, key_name): + """Iterate schema to find a key.""" + for schema_key in schema: + if schema_key == key_name: + return schema_key.default() + raise KeyError(f"{key_name} not found in schema") + + +async def test_zeroconf_pre_token_firmware( + hass: HomeAssistant, setup_enphase_envoy +) -> None: """Test we can setup from zeroconf.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -179,13 +180,55 @@ async def test_zeroconf(hass: HomeAssistant, setup_enphase_envoy) -> None: hostname="mock_hostname", name="mock_name", port=None, - properties={"serialnum": "1234"}, + properties={"serialnum": "1234", "protovers": "3.0.0"}, type="mock_type", ), ) assert result["type"] == "form" assert result["step_id"] == "user" + assert _get_schema_default(result["data_schema"].schema, "username") == "installer" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == "create_entry" + assert result2["title"] == "Envoy 1234" + assert result2["result"].unique_id == "1234" + assert result2["data"] == { + "host": "1.1.1.1", + "name": "Envoy 1234", + "username": "test-username", + "password": "test-password", + } + + +async def test_zeroconf_token_firmware( + hass: HomeAssistant, setup_enphase_envoy +) -> None: + """Test we can setup from zeroconf.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="1.1.1.1", + addresses=["1.1.1.1"], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234", "protovers": "7.0.0"}, + type="mock_type", + ), + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert _get_schema_default(result["data_schema"].schema, "username") == "" + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -311,7 +354,6 @@ async def test_reauth(hass: HomeAssistant, config_entry, setup_enphase_envoy) -> result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", "username": "test-username", "password": "test-password", }, diff --git a/tests/components/enphase_envoy/test_diagnostics.py b/tests/components/enphase_envoy/test_diagnostics.py index 5fd69d7bfb9..c3659b2a9bb 100644 --- a/tests/components/enphase_envoy/test_diagnostics.py +++ b/tests/components/enphase_envoy/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Enphase Envoy diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,53 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_enphase_envoy, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "enphase_envoy", - "title": REDACTED, - "data": { - "host": "1.1.1.1", - "name": REDACTED, - "username": REDACTED, - "password": REDACTED, - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "production": 1840, - "daily_production": 28223, - "seven_days_production": 174482, - "lifetime_production": 5924391, - "consumption": 1840, - "daily_consumption": 5923857, - "seven_days_consumption": 5923857, - "lifetime_consumption": 5923857, - "inverters_production": { - "202140024014": [136, "2022-10-08 16:43:36"], - "202140023294": [163, "2022-10-08 16:43:41"], - "202140013819": [130, "2022-10-08 16:43:31"], - "202140023794": [139, "2022-10-08 16:43:38"], - "202140023381": [130, "2022-10-08 16:43:47"], - "202140024176": [54, "2022-10-08 16:43:59"], - "202140003284": [132, "2022-10-08 16:43:55"], - "202140019854": [129, "2022-10-08 16:43:58"], - "202140020743": [131, "2022-10-08 16:43:49"], - "202140023531": [28, "2022-10-08 16:43:53"], - "202140024241": [164, "2022-10-08 16:43:33"], - "202140022963": [164, "2022-10-08 16:43:41"], - "202140023149": [118, "2022-10-08 16:43:47"], - "202140024828": [129, "2022-10-08 16:43:36"], - "202140023269": [133, "2022-10-08 16:43:43"], - "202140024157": [112, "2022-10-08 16:43:52"], - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/environment_canada/fixtures/config_entry_data.json b/tests/components/environment_canada/fixtures/config_entry_data.json deleted file mode 100644 index 085a3394dce..00000000000 --- a/tests/components/environment_canada/fixtures/config_entry_data.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "config_entry_data": { - "latitude": "**REDACTED**", - "longitude": "**REDACTED**", - "station": "XX/1234567", - "language": "Gibberish" - }, - "weather_data": { - "temperature": { - "label": "Temperature", - "value": 14.9, - "unit": "C" - }, - "dewpoint": { - "label": "Dew Point", - "value": 1.4, - "unit": "C" - }, - "wind_chill": { - "label": "Wind Chill", - "value": null - }, - "humidex": { - "label": "Humidex", - "value": null - }, - "pressure": { - "label": "Pressure", - "value": 102.7, - "unit": "kPa" - }, - "tendency": { - "label": "Tendency", - "value": "falling" - }, - "humidity": { - "label": "Humidity", - "value": 40, - "unit": "%" - }, - "visibility": { - "label": "Visibility", - "value": 24.1, - "unit": "km" - }, - "condition": { - "label": "Condition", - "value": "Mainly Sunny" - }, - "wind_speed": { - "label": "Wind Speed", - "value": 1, - "unit": "km/h" - }, - "wind_gust": { - "label": "Wind Gust", - "value": null - }, - "wind_dir": { - "label": "Wind Direction", - "value": "N" - }, - "wind_bearing": { - "label": "Wind Bearing", - "value": 0, - "unit": "degrees" - }, - "high_temp": { - "label": "High Temperature", - "value": 18, - "unit": "C" - }, - "low_temp": { - "label": "Low Temperature", - "value": -1, - "unit": "C" - }, - "uv_index": { - "label": "UV Index", - "value": 5 - }, - "pop": { - "label": "Chance of Precip.", - "value": null - }, - "icon_code": { - "label": "Icon Code", - "value": "01" - }, - "precip_yesterday": { - "label": "Precipitation Yesterday", - "value": 0.0, - "unit": "mm" - }, - "normal_high": { - "label": "Normal High Temperature", - "value": 15, - "unit": "C" - }, - "normal_low": { - "label": "Normal Low Temperature", - "value": 6, - "unit": "C" - }, - "text_summary": { - "label": "Forecast", - "value": "Tonight. Clear. Fog patches developing after midnight. Low minus 1 with frost." - } - } -} diff --git a/tests/components/environment_canada/snapshots/test_diagnostics.ambr b/tests/components/environment_canada/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..94ed1d88201 --- /dev/null +++ b/tests/components/environment_canada/snapshots/test_diagnostics.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry_data': dict({ + 'language': 'Gibberish', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'station': 'XX/1234567', + }), + 'weather_data': dict({ + 'condition': dict({ + 'label': 'Condition', + 'value': 'Mainly Sunny', + }), + 'dewpoint': dict({ + 'label': 'Dew Point', + 'unit': 'C', + 'value': 1.4, + }), + 'high_temp': dict({ + 'label': 'High Temperature', + 'unit': 'C', + 'value': 18, + }), + 'humidex': dict({ + 'label': 'Humidex', + 'value': None, + }), + 'humidity': dict({ + 'label': 'Humidity', + 'unit': '%', + 'value': 40, + }), + 'icon_code': dict({ + 'label': 'Icon Code', + 'value': '01', + }), + 'low_temp': dict({ + 'label': 'Low Temperature', + 'unit': 'C', + 'value': -1, + }), + 'normal_high': dict({ + 'label': 'Normal High Temperature', + 'unit': 'C', + 'value': 15, + }), + 'normal_low': dict({ + 'label': 'Normal Low Temperature', + 'unit': 'C', + 'value': 6, + }), + 'pop': dict({ + 'label': 'Chance of Precip.', + 'value': None, + }), + 'precip_yesterday': dict({ + 'label': 'Precipitation Yesterday', + 'unit': 'mm', + 'value': 0.0, + }), + 'pressure': dict({ + 'label': 'Pressure', + 'unit': 'kPa', + 'value': 102.7, + }), + 'temperature': dict({ + 'label': 'Temperature', + 'unit': 'C', + 'value': 14.9, + }), + 'tendency': dict({ + 'label': 'Tendency', + 'value': 'falling', + }), + 'text_summary': dict({ + 'label': 'Forecast', + 'value': 'Tonight. Clear. Fog patches developing after midnight. Low minus 1 with frost.', + }), + 'uv_index': dict({ + 'label': 'UV Index', + 'value': 5, + }), + 'visibility': dict({ + 'label': 'Visibility', + 'unit': 'km', + 'value': 24.1, + }), + 'wind_bearing': dict({ + 'label': 'Wind Bearing', + 'unit': 'degrees', + 'value': 0, + }), + 'wind_chill': dict({ + 'label': 'Wind Chill', + 'value': None, + }), + 'wind_dir': dict({ + 'label': 'Wind Direction', + 'value': 'N', + }), + 'wind_gust': dict({ + 'label': 'Wind Gust', + 'value': None, + }), + 'wind_speed': dict({ + 'label': 'Wind Speed', + 'unit': 'km/h', + 'value': 1, + }), + }), + }) +# --- diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index f85de2cb97c..3eedb7a0ddb 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -1,8 +1,10 @@ """Test Environment Canada diagnostics.""" -from datetime import datetime, timezone +from datetime import UTC, datetime import json from unittest.mock import AsyncMock, MagicMock, patch +from syrupy import SnapshotAssertion + from homeassistant.components.environment_canada.const import ( CONF_LANGUAGE, CONF_STATION, @@ -43,7 +45,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: ) weather_mock = mock_ec() - ec_data["metadata"]["timestamp"] = datetime(2022, 10, 4, tzinfo=timezone.utc) + ec_data["metadata"]["timestamp"] = datetime(2022, 10, 4, tzinfo=UTC) weather_mock.conditions = ec_data["conditions"] weather_mock.alerts = ec_data["alerts"] weather_mock.daily_forecasts = ec_data["daily_forecasts"] @@ -51,7 +53,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: radar_mock = mock_ec() radar_mock.image = b"GIF..." - radar_mock.timestamp = datetime(2022, 10, 4, tzinfo=timezone.utc) + radar_mock.timestamp = datetime(2022, 10, 4, tzinfo=UTC) with patch( "homeassistant.components.environment_canada.ECWeather", @@ -72,7 +74,9 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -80,8 +84,5 @@ async def test_entry_diagnostics( diagnostics = await get_diagnostics_for_config_entry( hass, hass_client, config_entry ) - redacted_entry = json.loads( - load_fixture("environment_canada/config_entry_data.json") - ) - assert diagnostics == redacted_entry + assert diagnostics == snapshot diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index f373c2fdb17..6b06545a06b 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -1,6 +1,7 @@ """esphome session fixtures.""" from __future__ import annotations +import asyncio from asyncio import Event from collections.abc import Awaitable, Callable from typing import Any @@ -15,13 +16,10 @@ from aioesphomeapi import ( ReconnectLogic, UserService, ) -import async_timeout import pytest from zeroconf import Zeroconf -from homeassistant.components.esphome import ( - dashboard, -) +from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, @@ -64,6 +62,7 @@ def mock_config_entry(hass) -> MockConfigEntry: """Return the default mocked config entry.""" config_entry = MockConfigEntry( title="ESPHome Device", + entry_id="08d821dc059cf4f645cb024d32c8e708", domain=DOMAIN, data={ CONF_HOST: "192.168.1.2", @@ -254,7 +253,7 @@ async def _mock_generic_device_entry( "homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic ): assert await hass.config_entries.async_setup(entry.entry_id) - async with async_timeout.timeout(2): + async with asyncio.timeout(2): await try_connect_done.wait() await hass.async_block_till_done() diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d8de8f06bc6 --- /dev/null +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'data': dict({ + 'device_name': 'test', + 'host': '192.168.1.2', + 'noise_psk': '**REDACTED**', + 'password': '**REDACTED**', + 'port': 6053, + }), + 'disabled_by': None, + 'domain': 'esphome', + 'entry_id': '08d821dc059cf4f645cb024d32c8e708', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'ESPHome Device', + 'unique_id': '11:22:33:44:55:aa', + 'version': 1, + }), + 'dashboard': 'mock-slug', + }) +# --- diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index 5a99f403394..e7409bdfae4 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -21,11 +21,7 @@ from homeassistant.components.alarm_control_panel import ( SERVICE_ALARM_TRIGGER, ) from homeassistant.components.esphome.alarm_control_panel import EspHomeACPFeatures -from homeassistant.const import ( - ATTR_ENTITY_ID, - STATE_ALARM_ARMED_AWAY, - STATE_UNKNOWN, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_UNKNOWN from homeassistant.core import HomeAssistant diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index f33026800e7..71406341175 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -5,10 +5,7 @@ from unittest.mock import call from aioesphomeapi import APIClient, ButtonInfo -from homeassistant.components.button import ( - DOMAIN as BUTTON_DOMAIN, - SERVICE_PRESS, -) +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index f9a25d6b5f2..bbf51c3bc12 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -10,9 +10,7 @@ from aioesphomeapi import ( UserService, ) -from homeassistant.components.camera import ( - STATE_IDLE, -) +from homeassistant.components.camera import STATE_IDLE from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index fc37e1e51ee..63e18107623 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -17,10 +17,7 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp, zeroconf -from homeassistant.components.esphome import ( - DomainData, - dashboard, -) +from homeassistant.components.esphome import DomainData, dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, CONF_DEVICE_NAME, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index a77fd9b0087..6000b270d87 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -1,15 +1,8 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from syrupy import SnapshotAssertion - -from homeassistant.components.esphome.const import ( - CONF_DEVICE_NAME, - CONF_NOISE_PSK, -) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant -from . import DASHBOARD_SLUG - from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -21,17 +14,9 @@ async def test_diagnostics( init_integration: MockConfigEntry, enable_bluetooth: None, mock_dashboard, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for config entry.""" result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - assert isinstance(result, dict) - assert result["config"]["data"] == { - CONF_DEVICE_NAME: "test", - CONF_HOST: "192.168.1.2", - CONF_PORT: 6053, - CONF_PASSWORD: "**REDACTED**", - CONF_NOISE_PSK: "**REDACTED**", - } - assert result["config"]["unique_id"] == "11:22:33:44:55:aa" - assert result["dashboard"] == DASHBOARD_SLUG + assert result == snapshot diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index ac121a93eff..fdc57b2dc24 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -8,10 +8,12 @@ from aioesphomeapi import ( BinarySensorState, EntityInfo, EntityState, + SensorInfo, + SensorState, UserService, ) -from homeassistant.const import ATTR_RESTORED, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_RESTORED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from .conftest import MockESPHomeDevice @@ -149,10 +151,17 @@ async def test_deep_sleep_device( name="my binary_sensor", unique_id="my_binary_sensor", ), + SensorInfo( + object_id="my_sensor", + key=3, + name="my sensor", + unique_id="my_sensor", + ), ] states = [ BinarySensorState(key=1, state=True, missing_state=False), BinarySensorState(key=2, state=True, missing_state=False), + SensorState(key=3, state=123.0, missing_state=False), ] user_service = [] mock_device = await mock_esphome_device( @@ -165,12 +174,18 @@ async def test_deep_sleep_device( state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "123" await mock_device.mock_disconnect(False) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == STATE_UNAVAILABLE await mock_device.mock_connect() await hass.async_block_till_done() @@ -178,12 +193,43 @@ async def test_deep_sleep_device( state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "123" + + await mock_device.mock_disconnect(True) + await hass.async_block_till_done() + await mock_device.mock_connect() + await hass.async_block_till_done() + mock_device.set_state(BinarySensorState(key=1, state=False, missing_state=False)) + mock_device.set_state(SensorState(key=3, state=56, missing_state=False)) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_OFF + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "56" await mock_device.mock_disconnect(True) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None - assert state.state == STATE_ON + assert state.state == STATE_OFF + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == "56" + + await mock_device.mock_connect() + await hass.async_block_till_done() + await mock_device.mock_disconnect(False) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_esphome_device_without_friendly_name( diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index d3d47a40d66..8e7e228e422 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -1,7 +1,5 @@ """ESPHome set up tests.""" -from unittest.mock import AsyncMock -from aioesphomeapi import DeviceInfo from homeassistant.components.esphome import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT @@ -10,29 +8,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_unique_id_updated_to_mac( - hass: HomeAssistant, mock_client, mock_zeroconf: None -) -> None: - """Test we update config entry unique ID to MAC address.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, - unique_id="mock-config-name", - ) - entry.add_to_hass(hass) - - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455aa", - ) - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.unique_id == "11:22:33:44:55:aa" - - async def test_delete_entry( hass: HomeAssistant, mock_client, mock_zeroconf: None ) -> None: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 7a487f3a385..d297dddee4a 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,16 +1,20 @@ """Test ESPHome manager.""" from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock -from aioesphomeapi import ( - APIClient, - EntityInfo, - EntityState, - UserService, +from aioesphomeapi import APIClient, DeviceInfo, EntityInfo, EntityState, UserService +import pytest + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.components.esphome.const import ( + CONF_DEVICE_NAME, + DOMAIN, + STABLE_BLE_VERSION_STR, ) - -from homeassistant.components.esphome.const import DOMAIN, STABLE_BLE_VERSION_STR from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir from .conftest import MockESPHomeDevice @@ -118,3 +122,213 @@ async def test_esphome_device_with_current_bluetooth( ) is None ) + + +async def test_unique_id_updated_to_mac( + hass: HomeAssistant, mock_client, mock_zeroconf: None +) -> None: + """Test we update config entry unique ID to MAC address.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_unique_id_not_updated_if_name_same_and_already_mac( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we never update the entry unique ID event if the name is the same.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + 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( + return_value=DeviceInfo(mac_address="1122334455ab", name="test") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Mac should never update + assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_unique_id_updated_if_name_unset_and_already_mac( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we never update config entry unique ID even if the name is unset.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="test") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Mac should never update + assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_unique_id_not_updated_if_name_different_and_already_mac( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we do not update config entry unique ID if the name is different.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + 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( + return_value=DeviceInfo(mac_address="1122334455ab", name="different") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Mac should not be updated because name is different + assert entry.unique_id == "11:22:33:44:55:aa" + # Name should not be updated either + assert entry.data[CONF_DEVICE_NAME] == "test" + + +async def test_name_updated_only_if_mac_matches( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we update config entry name only if the mac matches.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "old", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="new") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data[CONF_DEVICE_NAME] == "new" + + +async def test_name_updated_only_if_mac_was_unset( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we update config entry name if the old unique id was not a mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "old", + }, + unique_id="notamac", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="new") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data[CONF_DEVICE_NAME] == "new" + + +async def test_connection_aborted_wrong_device( + hass: HomeAssistant, + mock_client: APIClient, + mock_zeroconf: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we abort the connection if the unique id is a mac and neither name or mac match.""" + 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( + return_value=DeviceInfo(mac_address="1122334455ab", name="different") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + "Unexpected device found at 192.168.43.183; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `different` " + "with mac address `11:22:33:44:55:ab`" in caplog.text + ) + + caplog.clear() + # Make sure discovery triggers a reconnect to the correct device + service_info = dhcp.DhcpServiceInfo( + ip="192.168.43.184", + hostname="test", + macaddress="1122334455aa", + ) + new_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="test") + ) + mock_client.device_info = new_info + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == "192.168.43.184" + await hass.async_block_till_done() + assert len(new_info.mock_calls) == 1 + assert "Unexpected device found at" not in caplog.text diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index ca97d9abeba..ffbe8f50e48 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -32,9 +32,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import ( - mock_platform, -) +from tests.common import mock_platform from tests.typing import WebSocketGenerator diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 83661a58280..e46906ffd33 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -18,11 +18,7 @@ from aioesphomeapi import ( ) from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import ( - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNKNOWN, -) +from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index bd38f4d3302..d7b04f8448c 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -4,24 +4,12 @@ from collections.abc import Awaitable, Callable import dataclasses from unittest.mock import Mock, patch -from aioesphomeapi import ( - APIClient, - EntityInfo, - EntityState, - UserService, -) +from aioesphomeapi import APIClient, EntityInfo, EntityState, UserService import pytest -from homeassistant.components.esphome.dashboard import ( - async_get_dashboard, -) +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 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 diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 4188e375907..b7ce5670441 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -5,10 +5,15 @@ import socket from unittest.mock import Mock, patch from aioesphomeapi import VoiceAssistantEventType -import async_timeout import pytest -from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType +from homeassistant.components.assist_pipeline import ( + PipelineEvent, + PipelineEventType, + PipelineNotFound, + PipelineStage, +) +from homeassistant.components.assist_pipeline.error import WakeWordDetectionError from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant @@ -72,6 +77,13 @@ async def test_pipeline_events( event_callback = kwargs["event_callback"] + event_callback( + PipelineEvent( + type=PipelineEventType.WAKE_WORD_END, + data={"wake_word_output": {}}, + ) + ) + # Fake events event_callback( PipelineEvent( @@ -113,6 +125,8 @@ async def test_pipeline_events( elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert data is not None assert data["url"] == _TEST_OUTPUT_URL + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: + assert data is None voice_assistant_udp_server_v1.handle_event = handle_event @@ -148,7 +162,7 @@ async def test_udp_server( sock.sendto(b"test", ("127.0.0.1", port)) # Give the socket some time to send/receive the data - async with async_timeout.timeout(1): + async with asyncio.timeout(1): while voice_assistant_udp_server_v1.queue.qsize() == 0: await asyncio.sleep(0.1) @@ -344,134 +358,90 @@ async def test_send_tts( voice_assistant_udp_server_v2.transport.sendto.assert_called() -async def test_speech_detection( +async def test_wake_word( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: - """Test the UDP server queues incoming data.""" + """Test that the pipeline is set to start with Wake word.""" - def is_speech(self, chunk, sample_rate): - """Anything non-zero is speech.""" - return sum(chunk) > 0 - - async def async_pipeline_from_audio_stream(*args, **kwargs): - stt_stream = kwargs["stt_stream"] - event_callback = kwargs["event_callback"] - async for _chunk in stt_stream: - pass - - # Test empty data - event_callback( - PipelineEvent( - type=PipelineEventType.STT_END, - data={"stt_output": {"text": _TEST_INPUT_TEXT}}, - ) - ) + async def async_pipeline_from_audio_stream(*args, start_stage, **kwargs): + assert start_stage == PipelineStage.WAKE_WORD with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ), patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ): - voice_assistant_udp_server_v2.started = True - - voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) - voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * _ONE_SECOND * 2)) - voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * _ONE_SECOND * 2)) - voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) + voice_assistant_udp_server_v2.transport = Mock() await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="mock-device-id", + conversation_id=None, + flags=2, + pipeline_timeout=1, ) -async def test_no_speech( +async def test_wake_word_exception( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: - """Test there is no speech.""" - - def is_speech(self, chunk, sample_rate): - """Anything non-zero is speech.""" - return sum(chunk) > 0 - - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - assert event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR - assert data is not None - assert data["code"] == "speech-timeout" - - voice_assistant_udp_server_v2.handle_event = handle_event - - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - voice_assistant_udp_server_v2.started = True - - voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) - - await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 - ) - - -async def test_speech_timeout( - hass: HomeAssistant, - voice_assistant_udp_server_v2: VoiceAssistantUDPServer, -) -> None: - """Test when speech was detected, but the pipeline times out.""" - - def is_speech(self, chunk, sample_rate): - """Anything non-zero is speech.""" - return sum(chunk) > 255 + """Test that the pipeline is set to start with Wake word.""" async def async_pipeline_from_audio_stream(*args, **kwargs): - stt_stream = kwargs["stt_stream"] - async for _chunk in stt_stream: - # Stream will end when VAD detects end of "speech" - pass - - async def segment_audio(*args, **kwargs): - raise asyncio.TimeoutError() - async for chunk in []: - yield chunk + raise WakeWordDetectionError("pipeline-not-found", "Pipeline not found") with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ), patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, - ), patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._segment_audio", - new=segment_audio, ): - voice_assistant_udp_server_v2.started = True + voice_assistant_udp_server_v2.transport = Mock() - voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * (_ONE_SECOND * 2))) + def handle_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert data is not None + assert data["code"] == "pipeline-not-found" + assert data["message"] == "Pipeline not found" + + voice_assistant_udp_server_v2.handle_event = handle_event await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="mock-device-id", + conversation_id=None, + flags=2, + pipeline_timeout=1, ) -async def test_cancelled( +async def test_pipeline_timeout( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: - """Test when the server is stopped while waiting for speech.""" + """Test that the pipeline is set to start with Wake word.""" - voice_assistant_udp_server_v2.started = True + async def async_pipeline_from_audio_stream(*args, **kwargs): + raise PipelineNotFound("not-found", "Pipeline not found") - voice_assistant_udp_server_v2.queue.put_nowait(b"") + with patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ): + voice_assistant_udp_server_v2.transport = Mock() - await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 - ) + def handle_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert data is not None + assert data["code"] == "pipeline not found" + assert data["message"] == "Selected pipeline not found" - # No events should be sent if cancelled while waiting for speech - voice_assistant_udp_server_v2.handle_event.assert_not_called() + voice_assistant_udp_server_v2.handle_event = handle_event + + await voice_assistant_udp_server_v2.run_pipeline( + device_id="mock-device-id", + conversation_id=None, + flags=2, + pipeline_timeout=1, + ) diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 61851559969..345c37dc8f1 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -1,7 +1,11 @@ """The tests for the feedreader component.""" -from datetime import timedelta +from collections.abc import Generator +from datetime import datetime, timedelta +import pickle +from time import gmtime +from typing import Any from unittest import mock -from unittest.mock import mock_open, patch +from unittest.mock import MagicMock, mock_open, patch import pytest @@ -13,7 +17,7 @@ from homeassistant.components.feedreader import ( EVENT_FEEDREADER, ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -27,7 +31,7 @@ VALID_CONFIG_4 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 5}} VALID_CONFIG_5 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 1}} -def load_fixture_bytes(src): +def load_fixture_bytes(src: str) -> bytes: """Return byte stream of fixture.""" feed_data = load_fixture(src) raw = bytes(feed_data, "utf-8") @@ -35,72 +39,198 @@ def load_fixture_bytes(src): @pytest.fixture(name="feed_one_event") -def fixture_feed_one_event(hass): +def fixture_feed_one_event(hass: HomeAssistant) -> bytes: """Load test feed data for one event.""" return load_fixture_bytes("feedreader.xml") @pytest.fixture(name="feed_two_event") -def fixture_feed_two_events(hass): +def fixture_feed_two_events(hass: HomeAssistant) -> bytes: """Load test feed data for two event.""" return load_fixture_bytes("feedreader1.xml") @pytest.fixture(name="feed_21_events") -def fixture_feed_21_events(hass): +def fixture_feed_21_events(hass: HomeAssistant) -> bytes: """Load test feed data for twenty one events.""" return load_fixture_bytes("feedreader2.xml") @pytest.fixture(name="feed_three_events") -def fixture_feed_three_events(hass): +def fixture_feed_three_events(hass: HomeAssistant) -> bytes: """Load test feed data for three events.""" return load_fixture_bytes("feedreader3.xml") @pytest.fixture(name="feed_atom_event") -def fixture_feed_atom_event(hass): +def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: """Load test feed data for atom event.""" return load_fixture_bytes("feedreader5.xml") @pytest.fixture(name="events") -async def fixture_events(hass): +async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" return async_capture_events(hass, EVENT_FEEDREADER) -@pytest.fixture(name="feed_storage", autouse=True) -def fixture_feed_storage(): +@pytest.fixture(name="storage") +def fixture_storage(request: pytest.FixtureRequest) -> Generator[None, None, None]: + """Set up the test storage environment.""" + if request.param == "legacy_storage": + with patch("os.path.exists", return_value=False): + yield + elif request.param == "json_storage": + with patch("os.path.exists", return_value=True): + yield + else: + raise RuntimeError("Invalid storage fixture") + + +@pytest.fixture(name="legacy_storage_open") +def fixture_legacy_storage_open() -> Generator[MagicMock, None, None]: """Mock builtins.open for feedreader storage.""" - with patch("homeassistant.components.feedreader.open", mock_open(), create=True): - yield - - -async def test_setup_one_feed(hass: HomeAssistant) -> None: - """Test the general setup of this component.""" with patch( - "homeassistant.components.feedreader.track_time_interval" + "homeassistant.components.feedreader.open", + mock_open(), + create=True, + ) as open_mock: + yield open_mock + + +@pytest.fixture(name="legacy_storage_load", autouse=True) +def fixture_legacy_storage_load( + legacy_storage_open, +) -> Generator[MagicMock, None, None]: + """Mock builtins.open for feedreader storage.""" + with patch( + "homeassistant.components.feedreader.pickle.load", return_value={} + ) as pickle_load: + yield pickle_load + + +async def test_setup_no_feeds(hass: HomeAssistant) -> None: + """Test config with no urls.""" + assert not await async_setup_component( + hass, feedreader.DOMAIN, {feedreader.DOMAIN: {CONF_URLS: []}} + ) + + +@pytest.mark.parametrize( + ("open_error", "load_error"), + [ + (FileNotFoundError("No file"), None), + (OSError("Boom"), None), + (None, pickle.PickleError("Bad data")), + ], +) +async def test_legacy_storage_error( + hass: HomeAssistant, + legacy_storage_open: MagicMock, + legacy_storage_load: MagicMock, + open_error: Exception | None, + load_error: Exception | None, +) -> None: + """Test legacy storage error.""" + legacy_storage_open.side_effect = open_error + legacy_storage_load.side_effect = load_error + + with patch( + "homeassistant.components.feedreader.async_track_time_interval" ) as track_method: assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_1) await hass.async_block_till_done() - track_method.assert_called_once_with( - hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True - ) + track_method.assert_called_once_with( + hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True + ) + + +@pytest.mark.parametrize("storage", ["legacy_storage", "json_storage"], indirect=True) +async def test_storage_data_loading( + hass: HomeAssistant, + events: list[Event], + feed_one_event: bytes, + legacy_storage_load: MagicMock, + hass_storage: dict[str, Any], + storage: None, +) -> None: + """Test loading existing storage data.""" + storage_data: dict[str, str] = {URL: "2018-04-30T05:10:00+00:00"} + hass_storage[feedreader.DOMAIN] = { + "version": 1, + "minor_version": 1, + "key": feedreader.DOMAIN, + "data": storage_data, + } + legacy_storage_data = { + URL: gmtime(datetime.fromisoformat(storage_data[URL]).timestamp()) + } + legacy_storage_load.return_value = legacy_storage_data + + with patch( + "feedparser.http.get", + return_value=feed_one_event, + ): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + # no new events + assert not events + + +async def test_storage_data_writing( + hass: HomeAssistant, + events: list[Event], + feed_one_event: bytes, + hass_storage: dict[str, Any], +) -> None: + """Test writing to storage.""" + storage_data: dict[str, str] = {URL: "2018-04-30T05:10:00+00:00"} + + with patch( + "feedparser.http.get", + return_value=feed_one_event, + ), patch("homeassistant.components.feedreader.DELAY_SAVE", new=0): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + # one new event + assert len(events) == 1 + + # storage data updated + assert hass_storage[feedreader.DOMAIN]["data"] == storage_data + + +@pytest.mark.parametrize("storage", ["legacy_storage", "json_storage"], indirect=True) +async def test_setup_one_feed(hass: HomeAssistant, storage: None) -> None: + """Test the general setup of this component.""" + with patch( + "homeassistant.components.feedreader.async_track_time_interval" + ) as track_method: + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_1) + await hass.async_block_till_done() + + track_method.assert_called_once_with( + hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True + ) async def test_setup_scan_interval(hass: HomeAssistant) -> None: """Test the setup of this component with scan interval.""" with patch( - "homeassistant.components.feedreader.track_time_interval" + "homeassistant.components.feedreader.async_track_time_interval" ) as track_method: assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) await hass.async_block_till_done() - track_method.assert_called_once_with( - hass, mock.ANY, timedelta(seconds=60), cancel_on_shutdown=True - ) + track_method.assert_called_once_with( + hass, mock.ANY, timedelta(seconds=60), cancel_on_shutdown=True + ) async def test_setup_max_entries(hass: HomeAssistant) -> None: diff --git a/tests/components/ffmpeg/test_binary_sensor.py b/tests/components/ffmpeg/test_binary_sensor.py new file mode 100644 index 00000000000..6eec115d6f0 --- /dev/null +++ b/tests/components/ffmpeg/test_binary_sensor.py @@ -0,0 +1,127 @@ +"""The tests for Home Assistant ffmpeg binary sensor.""" +from unittest.mock import AsyncMock, patch + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component + +CONFIG_NOISE = { + "binary_sensor": {"platform": "ffmpeg_noise", "input": "testinputvideo"} +} +CONFIG_MOTION = { + "binary_sensor": {"platform": "ffmpeg_motion", "input": "testinputvideo"} +} + + +# -- ffmpeg noise binary_sensor -- + + +async def test_noise_setup_component(hass: HomeAssistant) -> None: + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_NOISE) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_noise") is not None + + +@patch("haffmpeg.sensor.SensorNoise.open_sensor", side_effect=AsyncMock()) +async def test_noise_setup_component_start(mock_start, hass: HomeAssistant): + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_NOISE) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_noise") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert mock_start.called + + entity = hass.states.get("binary_sensor.ffmpeg_noise") + assert entity.state == "unavailable" + + +@patch("haffmpeg.sensor.SensorNoise") +async def test_noise_setup_component_start_callback(mock_ffmpeg, hass: HomeAssistant): + """Set up ffmpeg component.""" + mock_ffmpeg().open_sensor.side_effect = AsyncMock() + mock_ffmpeg().close = AsyncMock() + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_NOISE) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_noise") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_noise") + assert entity.state == "off" + + hass.async_add_job(mock_ffmpeg.call_args[0][1], True) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_noise") + assert entity.state == "on" + + +# -- ffmpeg motion binary_sensor -- + + +async def test_motion_setup_component(hass: HomeAssistant) -> None: + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_MOTION) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_motion") is not None + + +@patch("haffmpeg.sensor.SensorMotion.open_sensor", side_effect=AsyncMock()) +async def test_motion_setup_component_start(mock_start, hass: HomeAssistant): + """Set up ffmpeg component.""" + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_MOTION) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_motion") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert mock_start.called + + entity = hass.states.get("binary_sensor.ffmpeg_motion") + assert entity.state == "unavailable" + + +@patch("haffmpeg.sensor.SensorMotion") +async def test_motion_setup_component_start_callback(mock_ffmpeg, hass: HomeAssistant): + """Set up ffmpeg component.""" + mock_ffmpeg().open_sensor.side_effect = AsyncMock() + mock_ffmpeg().close = AsyncMock() + with assert_setup_component(1, "binary_sensor"): + await async_setup_component(hass, "binary_sensor", CONFIG_MOTION) + await hass.async_block_till_done() + + assert hass.data["ffmpeg"].binary == "ffmpeg" + assert hass.states.get("binary_sensor.ffmpeg_motion") is not None + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_motion") + assert entity.state == "off" + + hass.async_add_job(mock_ffmpeg.call_args[0][1], True) + await hass.async_block_till_done() + + entity = hass.states.get("binary_sensor.ffmpeg_motion") + assert entity.state == "on" diff --git a/tests/components/ffmpeg/test_sensor.py b/tests/components/ffmpeg/test_sensor.py deleted file mode 100644 index a6c9c1f441a..00000000000 --- a/tests/components/ffmpeg/test_sensor.py +++ /dev/null @@ -1,130 +0,0 @@ -"""The tests for Home Assistant ffmpeg binary sensor.""" -from unittest.mock import patch - -from homeassistant.setup import setup_component - -from tests.common import assert_setup_component, get_test_home_assistant, mock_coro - - -class TestFFmpegNoiseSetup: - """Test class for ffmpeg.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.config = { - "binary_sensor": {"platform": "ffmpeg_noise", "input": "testinputvideo"} - } - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_noise") is not None - - @patch("haffmpeg.sensor.SensorNoise.open_sensor", return_value=mock_coro()) - def test_setup_component_start(self, mock_start): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_noise") is not None - - self.hass.start() - assert mock_start.called - - entity = self.hass.states.get("binary_sensor.ffmpeg_noise") - assert entity.state == "unavailable" - - @patch("haffmpeg.sensor.SensorNoise") - def test_setup_component_start_callback(self, mock_ffmpeg): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_noise") is not None - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.ffmpeg_noise") - assert entity.state == "off" - - self.hass.add_job(mock_ffmpeg.call_args[0][1], True) - self.hass.block_till_done() - - entity = self.hass.states.get("binary_sensor.ffmpeg_noise") - assert entity.state == "on" - - -class TestFFmpegMotionSetup: - """Test class for ffmpeg.""" - - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.config = { - "binary_sensor": {"platform": "ffmpeg_motion", "input": "testinputvideo"} - } - - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_motion") is not None - - @patch("haffmpeg.sensor.SensorMotion.open_sensor", return_value=mock_coro()) - def test_setup_component_start(self, mock_start): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_motion") is not None - - self.hass.start() - assert mock_start.called - - entity = self.hass.states.get("binary_sensor.ffmpeg_motion") - assert entity.state == "unavailable" - - @patch("haffmpeg.sensor.SensorMotion") - def test_setup_component_start_callback(self, mock_ffmpeg): - """Set up ffmpeg component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config) - self.hass.block_till_done() - - assert self.hass.data["ffmpeg"].binary == "ffmpeg" - assert self.hass.states.get("binary_sensor.ffmpeg_motion") is not None - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.ffmpeg_motion") - assert entity.state == "off" - - self.hass.add_job(mock_ffmpeg.call_args[0][1], True) - self.hass.block_till_done() - - entity = self.hass.states.get("binary_sensor.ffmpeg_motion") - assert entity.state == "on" diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 056b23e1cf4..8a2bbcbcd4a 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -6,10 +6,12 @@ from pyfibaro.fibaro_scene import SceneModel import pytest from homeassistant.components.fibaro import DOMAIN, FIBARO_CONTROLLER, FIBARO_DEVICES -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -39,13 +41,8 @@ async def setup_platform( ) -> ConfigEntry: """Set up the fibaro platform and prerequisites.""" hass.config.components.add(DOMAIN) - config_entry = ConfigEntry( - 1, - DOMAIN, - "Test", - {}, - SOURCE_USER, - ) + config_entry = MockConfigEntry(domain=DOMAIN, title="Test") + config_entry.add_to_hass(hass) controller_mock = Mock() controller_mock.hub_serial = "HC2-111111" diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index a7d7439226c..ed8a4756031 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -302,7 +302,6 @@ async def test_flux_before_sunrise_known_location( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_after_sunrise_before_sunset( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -361,7 +360,6 @@ async def test_flux_after_sunrise_before_sunset( assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] -# pylint: disable=invalid-name async def test_flux_after_sunset_before_stop( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -421,7 +419,6 @@ async def test_flux_after_sunset_before_stop( assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385] -# pylint: disable=invalid-name async def test_flux_after_stop_before_sunrise( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -480,7 +477,6 @@ async def test_flux_after_stop_before_sunrise( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_with_custom_start_stop_times( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -603,7 +599,6 @@ async def test_flux_before_sunrise_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_after_sunrise_before_sunset_stop_next_day( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -666,7 +661,6 @@ async def test_flux_after_sunrise_before_sunset_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37] -# pylint: disable=invalid-name @pytest.mark.parametrize("x", [0, 1]) async def test_flux_after_sunset_before_midnight_stop_next_day( hass: HomeAssistant, x, enable_custom_integrations: None @@ -730,7 +724,6 @@ async def test_flux_after_sunset_before_midnight_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.588, 0.386] -# pylint: disable=invalid-name async def test_flux_after_sunset_after_midnight_stop_next_day( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -793,7 +786,6 @@ async def test_flux_after_sunset_after_midnight_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.601, 0.382] -# pylint: disable=invalid-name async def test_flux_after_stop_before_sunrise_stop_next_day( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -856,7 +848,6 @@ async def test_flux_after_stop_before_sunrise_stop_next_day( assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379] -# pylint: disable=invalid-name async def test_flux_with_custom_colortemps( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -918,7 +909,6 @@ async def test_flux_with_custom_colortemps( assert call.data[light.ATTR_XY_COLOR] == [0.469, 0.378] -# pylint: disable=invalid-name async def test_flux_with_custom_brightness( hass: HomeAssistant, enable_custom_integrations: None ) -> None: diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 60e9c9dc5d0..06cf39b4875 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -9,7 +9,8 @@ import pytest from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -37,6 +38,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( title="Green House", unique_id="unique", + version=2, domain=DOMAIN, data={ CONF_LATITUDE: 52.42, @@ -47,7 +49,8 @@ def mock_config_entry() -> MockConfigEntry: CONF_DECLINATION: 30, CONF_AZIMUTH: 190, CONF_MODULES_POWER: 5100, - CONF_DAMPING: 0.5, + CONF_DAMPING_MORNING: 0.5, + CONF_DAMPING_EVENING: 0.5, CONF_INVERTER_SIZE: 2000, }, ) diff --git a/tests/components/forecast_solar/snapshots/test_diagnostics.ambr b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..686721a9d4a --- /dev/null +++ b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'account': dict({ + 'rate_limit': 60, + 'timezone': 'Europe/Amsterdam', + 'type': 'public', + }), + 'data': dict({ + 'energy_current_hour': 800000, + 'energy_production_today': 100000, + 'energy_production_today_remaining': 50000, + 'energy_production_tomorrow': 200000, + 'power_production_now': 300000, + 'watts': dict({ + '2021-06-27T13:00:00-07:00': 10, + '2022-06-27T13:00:00-07:00': 100, + }), + 'wh_days': dict({ + '2021-06-27T13:00:00-07:00': 20, + '2022-06-27T13:00:00-07:00': 200, + }), + 'wh_period': dict({ + '2021-06-27T13:00:00-07:00': 30, + '2022-06-27T13:00:00-07:00': 300, + }), + }), + 'entry': dict({ + 'data': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'options': dict({ + 'api_key': '**REDACTED**', + 'azimuth': 190, + 'damping_evening': 0.5, + 'damping_morning': 0.5, + 'declination': 30, + 'inverter_size': 2000, + 'modules_power': 5100, + }), + 'title': 'Green House', + }), + }) +# --- diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr new file mode 100644 index 00000000000..a009105e2e6 --- /dev/null +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_migration + ConfigEntrySnapshot({ + 'data': dict({ + 'latitude': 52.42, + 'longitude': 4.42, + }), + 'disabled_by': None, + 'domain': 'forecast_solar', + 'entry_id': , + 'options': dict({ + 'api_key': 'abcdef12345', + 'azimuth': 190, + 'damping_evening': 0.5, + 'damping_morning': 0.5, + 'declination': 30, + 'inverter_size': 2000, + 'modules_power': 5100, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Green House', + 'unique_id': 'unique', + 'version': 2, + }) +# --- diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index 2129821217e..06aeb94542e 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -75,7 +76,8 @@ async def test_options_flow_invalid_api( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) @@ -108,7 +110,8 @@ async def test_options_flow( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) @@ -120,7 +123,8 @@ async def test_options_flow( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, } @@ -147,7 +151,8 @@ async def test_options_flow_without_key( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) @@ -159,6 +164,7 @@ async def test_options_flow_without_key( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, } diff --git a/tests/components/forecast_solar/test_diagnostics.py b/tests/components/forecast_solar/test_diagnostics.py index 4900c3bdb32..e72f2d7d9dc 100644 --- a/tests/components/forecast_solar/test_diagnostics.py +++ b/tests/components/forecast_solar/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Forecast.Solar integration.""" +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,48 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": { - "title": "Green House", - "data": { - "latitude": REDACTED, - "longitude": REDACTED, - }, - "options": { - "api_key": REDACTED, - "declination": 30, - "azimuth": 190, - "modules power": 5100, - "damping": 0.5, - "inverter_size": 2000, - }, - }, - "data": { - "energy_production_today": 100000, - "energy_production_today_remaining": 50000, - "energy_production_tomorrow": 200000, - "energy_current_hour": 800000, - "power_production_now": 300000, - "watts": { - "2021-06-27T13:00:00-07:00": 10, - "2022-06-27T13:00:00-07:00": 100, - }, - "wh_days": { - "2021-06-27T13:00:00-07:00": 20, - "2022-06-27T13:00:00-07:00": 200, - }, - "wh_period": { - "2021-06-27T13:00:00-07:00": 30, - "2022-06-27T13:00:00-07:00": 300, - }, - }, - "account": { - "type": "public", - "rate_limit": 60, - "timezone": "Europe/Amsterdam", - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/forecast_solar/test_energy.py b/tests/components/forecast_solar/test_energy.py index 3ca89d33faa..7d3a853b8a7 100644 --- a/tests/components/forecast_solar/test_energy.py +++ b/tests/components/forecast_solar/test_energy.py @@ -1,5 +1,5 @@ """Test forecast solar energy platform.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock from homeassistant.components.forecast_solar import energy @@ -16,8 +16,8 @@ async def test_energy_solar_forecast( ) -> None: """Test the Forecast.Solar energy platform solar forecast.""" mock_forecast_solar.estimate.return_value.wh_period = { - datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, - datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, + datetime(2021, 6, 27, 13, 0, tzinfo=UTC): 12, + datetime(2021, 6, 27, 14, 0, tzinfo=UTC): 8, } mock_config_entry.add_to_hass(hass) diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index a7696fe8f53..25dcb41c976 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -2,9 +2,17 @@ from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError +from syrupy import SnapshotAssertion -from homeassistant.components.forecast_solar.const import DOMAIN +from homeassistant.components.forecast_solar.const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_INVERTER_SIZE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -44,3 +52,29 @@ async def test_config_entry_not_ready( assert mock_request.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_migration(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test config entry version 1 -> 2 migration.""" + mock_config_entry = MockConfigEntry( + title="Green House", + unique_id="unique", + domain=DOMAIN, + data={ + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + }, + options={ + CONF_API_KEY: "abcdef12345", + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + "modules power": 5100, + CONF_DAMPING: 0.5, + CONF_INVERTER_SIZE: 2000, + }, + ) + 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 hass.config_entries.async_get_entry(mock_config_entry.entry_id) == snapshot diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index 3d540c1f5af..4d15f083591 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -35,6 +35,7 @@ async def test_async_browse_media( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -212,6 +213,7 @@ async def test_async_browse_media_not_found( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -366,6 +368,7 @@ async def test_async_browse_image( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -421,6 +424,7 @@ async def test_async_browse_image_missing( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index b950d44508d..69b250412bd 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import ( @@ -27,9 +28,10 @@ def mock_path(): @pytest.fixture -def mock_device_registry_devices(device_registry): +def mock_device_registry_devices(hass: HomeAssistant, device_registry): """Create device registry devices so the device tracker entities are enabled.""" config_entry = MockConfigEntry(domain="something_else") + config_entry.add_to_hass(hass) for idx, device in enumerate( ( diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 7028366d02b..a6253dbf315 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -1995,8 +1995,58 @@ DATA_HOME_GET_NODES = [ "Gateway": 1, "ItemId": "e76c2b75a4a6e2", }, - "show_endpoints": [{...}, {...}, {...}, {...}], - "signal_links": [{...}], + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Activé", + "name": "enable", + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 1, + "label": "Activé", + "name": "enable", + "value": True, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 2, + "label": "Bouton appuyé", + "name": "pushed", + "value": None, + "value_type": "int", + }, + { + "category": "", + "ep_type": "signal", + "id": 3, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "value": 100, + "value_type": "int", + }, + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 10, + "name": "node_7", + "status": "active", + }, + ], "slot_links": [], "status": "active", "type": { @@ -2081,12 +2131,119 @@ DATA_HOME_GET_NODES = [ "visibility": "normal", }, ], - "signal_links": [{...}], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 12, + "name": "node_7", + "status": "active", + } + ], "slot_links": [], "status": "active", "type": { "abstract": False, - "endpoints": [...], + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 3, + "label": "Alarme principale", + "name": "alarm1", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 4, + "label": "Alarme secondaire", + "name": "alarm2", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 5, + "label": "Zone temporisée", + "name": "timed", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "1cover", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "1battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 9, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 10, + "label": "Alarme", + "name": "alarm", + "param_type": "void", + "value_type": "void", + "visibility": "internal", + }, + ], "generic": False, "icon": "/resources/images/home/pictos/detecteur_ouverture.png", "inherit": "node::domus", @@ -2112,22 +2269,172 @@ DATA_HOME_GET_NODES = [ "ItemId": "240d000f9fefe576", }, "show_endpoints": [ - {...}, - {...}, - {...}, - {...}, - {...}, - {...}, - {...}, - {...}, - {...}, + { + "category": "", + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "ui": {"access": "w", "display": "toggle"}, + "value": False, + "value_type": "bool", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": { + "access": "r", + "display": "icon", + "icon_range": [...], + "icon_url": "/resources/images/home/pictos/batt_x.png", + "status_text_range": [...], + "unit": "%", + }, + "value": 100, + "value_type": "int", + "visibility": "normal", + }, + ], + "signal_links": [ + { + "adapter": 5, + "category": "alarm", + "id": 7, + "label": "Système d alarme", + "link_id": 12, + "name": "node_7", + "status": "active", + } ], - "signal_links": [{...}], "slot_links": [], "status": "active", "type": { "abstract": False, - "endpoints": [...], + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 2, + "label": "Zone temporisée", + "name": "timed", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 3, + "label": "Alarme principale", + "name": "alarm1", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 4, + "label": "Alarme secondaire", + "name": "alarm2", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 5, + "label": "Zone temporisée", + "name": "timed", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 6, + "label": "Détection", + "name": "trigger", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 7, + "label": "Couvercle", + "name": "cover", + "param_type": "void", + "value_type": "bool", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 8, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 9, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 10, + "label": "Alarme", + "name": "alarm", + "param_type": "void", + "value_type": "void", + "visibility": "internal", + }, + ], "generic": False, "icon": "/resources/images/home/pictos/detecteur_xxxx.png", "inherit": "node::domus", diff --git a/tests/components/freebox/test_button.py b/tests/components/freebox/test_button.py index aabf4682832..de15e90f54f 100644 --- a/tests/components/freebox/test_button.py +++ b/tests/components/freebox/test_button.py @@ -1,5 +1,7 @@ """Tests for the Freebox config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch + +from pytest_unordered import unordered from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.freebox.const import DOMAIN @@ -22,7 +24,7 @@ async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: entry.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert hass.config_entries.async_entries() == [entry] + assert hass.config_entries.async_entries() == unordered([entry, ANY]) assert router.call_count == 1 assert router().open.call_count == 1 diff --git a/tests/components/freebox/test_init.py b/tests/components/freebox/test_init.py index 6197f03b0ec..85acfdccc4d 100644 --- a/tests/components/freebox/test_init.py +++ b/tests/components/freebox/test_init.py @@ -1,5 +1,7 @@ """Tests for the Freebox config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch + +from pytest_unordered import unordered from homeassistant.components.device_tracker import DOMAIN as DT_DOMAIN from homeassistant.components.freebox.const import DOMAIN, SERVICE_REBOOT @@ -25,7 +27,7 @@ async def test_setup(hass: HomeAssistant, router: Mock) -> None: entry.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - assert hass.config_entries.async_entries() == [entry] + assert hass.config_entries.async_entries() == unordered([entry, ANY]) assert router.call_count == 1 assert router().open.call_count == 1 @@ -57,7 +59,7 @@ async def test_setup_import(hass: HomeAssistant, router: Mock) -> None: hass, DOMAIN, {DOMAIN: {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}} ) await hass.async_block_till_done() - assert hass.config_entries.async_entries() == [entry] + assert hass.config_entries.async_entries() == unordered([entry, ANY]) assert router.call_count == 1 assert router().open.call_count == 1 diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index acb135d01bb..08dce14f18d 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -26,7 +26,7 @@ class FritzServiceMock(Service): self.serviceId = serviceId -class FritzConnectionMock: # pylint: disable=too-few-public-methods +class FritzConnectionMock: """FritzConnection mocking.""" def __init__(self, services): diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index 99ca7a3b6c5..dbff4713553 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -8,11 +8,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import ( - MOCK_FIRMWARE_AVAILABLE, - MOCK_FIRMWARE_RELEASE_URL, - MOCK_USER_DATA, -) +from .const import MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL, MOCK_USER_DATA from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 28476d88273..dd5a8127185 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -205,7 +205,7 @@ async def test_coordinator_update_when_unreachable( unique_id="any", ) entry.add_to_hass(hass) - fritz().get_devices.side_effect = [ConnectionError(), ""] + fritz().update_devices.side_effect = [ConnectionError(), ""] assert not await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -258,6 +258,27 @@ async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> unique_id="any", ) entry.add_to_hass(hass) + with patch( + "homeassistant.components.fritzbox.Fritzhome.login", + side_effect=ConnectionError(), + ) as mock_login: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + mock_login.assert_called_once() + + entries = hass.config_entries.async_entries() + config_entry = entries[0] + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_raise_config_entry_error_when_login_fail(hass: HomeAssistant) -> None: + """Config entry state is SETUP_ERROR when login to fritzbox fail.""" + entry = MockConfigEntry( + domain=FB_DOMAIN, + data={CONF_HOST: "any", **MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0]}, + unique_id="any", + ) + entry.add_to_hass(hass) with patch( "homeassistant.components.fritzbox.Fritzhome.login", side_effect=LoginError("user"), diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 8a01665134b..b4c0209e9af 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -11,9 +11,11 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, + EntityCategory, UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from . import FritzDeviceSensorMock, setup_config_entry @@ -32,26 +34,44 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.async_block_till_done() - state = hass.states.get(f"{ENTITY_ID}_temperature") - assert state - assert state.state == "1.23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + sensors = ( + [ + f"{ENTITY_ID}_temperature", + "1.23", + f"{CONF_FAKE_NAME} Temperature", + UnitOfTemperature.CELSIUS, + SensorStateClass.MEASUREMENT, + None, + ], + [ + f"{ENTITY_ID}_humidity", + "42", + f"{CONF_FAKE_NAME} Humidity", + PERCENTAGE, + SensorStateClass.MEASUREMENT, + None, + ], + [ + f"{ENTITY_ID}_battery", + "23", + f"{CONF_FAKE_NAME} Battery", + PERCENTAGE, + None, + EntityCategory.DIAGNOSTIC, + ], + ) - state = hass.states.get(f"{ENTITY_ID}_humidity") - assert state - assert state.state == "42" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Humidity" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - - state = hass.states.get(f"{ENTITY_ID}_battery") - assert state - assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert ATTR_STATE_CLASS not in state.attributes + entity_registry = er.async_get(hass) + for sensor in sensors: + state = hass.states.get(sensor[0]) + assert state + assert state.state == sensor[1] + assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] + assert state.attributes.get(ATTR_STATE_CLASS) == sensor[4] + entry = entity_registry.async_get(sensor[0]) + assert entry + assert entry.entity_category is sensor[5] async def test_update(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 4ac36a13284..53cdf5147fc 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -20,6 +20,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, STATE_UNAVAILABLE, + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -27,6 +28,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from . import FritzDeviceSwitchMock, setup_config_entry @@ -60,6 +62,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: f"{CONF_FAKE_NAME} Temperature", UnitOfTemperature.CELSIUS, SensorStateClass.MEASUREMENT, + EntityCategory.DIAGNOSTIC, ], [ f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power", @@ -67,6 +70,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: f"{CONF_FAKE_NAME} Power", UnitOfPower.WATT, SensorStateClass.MEASUREMENT, + None, ], [ f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_energy", @@ -74,6 +78,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: f"{CONF_FAKE_NAME} Energy", UnitOfEnergy.KILO_WATT_HOUR, SensorStateClass.TOTAL_INCREASING, + None, ], [ f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_voltage", @@ -81,6 +86,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: f"{CONF_FAKE_NAME} Voltage", UnitOfElectricPotential.VOLT, SensorStateClass.MEASUREMENT, + None, ], [ f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_current", @@ -88,9 +94,11 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: f"{CONF_FAKE_NAME} Current", UnitOfElectricCurrent.AMPERE, SensorStateClass.MEASUREMENT, + None, ], ) + entity_registry = er.async_get(hass) for sensor in sensors: state = hass.states.get(sensor[0]) assert state @@ -98,6 +106,10 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] assert state.attributes[ATTR_STATE_CLASS] == sensor[4] + assert state.attributes[ATTR_STATE_CLASS] == sensor[4] + entry = entity_registry.async_get(sensor[0]) + assert entry + assert entry.entity_category is sensor[5] async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 4d11291508b..5a757da1e9c 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -6,7 +6,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -85,7 +84,7 @@ def mock_responses( ) -async def enable_all_entities(hass, config_entry_id, time_till_next_update): +async def enable_all_entities(hass, freezer, config_entry_id, time_till_next_update): """Enable all entities for a config entry and fast forward time to receive data.""" registry = er.async_get(hass) entities = er.async_entries_for_config_entry(registry, config_entry_id) @@ -96,5 +95,6 @@ async def enable_all_entities(hass, config_entry_id, time_till_next_update): ]: registry.async_update_entity(entry.entity_id, **{"disabled_by": None}) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + time_till_next_update) + freezer.tick(time_till_next_update) + async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py index a0e420c5b52..d4f42fadb06 100644 --- a/tests/components/fronius/test_coordinator.py +++ b/tests/components/fronius/test_coordinator.py @@ -1,13 +1,13 @@ """Test the Fronius update coordinators.""" from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pyfronius import BadStatusError, FroniusError from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, ) from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration @@ -16,7 +16,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_adaptive_update_interval( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test coordinators changing their update interval when inverter not available.""" with patch("pyfronius.Fronius.current_inverter_data") as mock_inverter_data: @@ -25,9 +27,8 @@ async def test_adaptive_update_interval( mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -35,33 +36,28 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = FroniusError() # first 3 bad requests at default interval - 4th has different interval for _ in range(3): - async_fire_time_changed( - hass, - dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() assert mock_inverter_data.call_count == 3 mock_inverter_data.reset_mock() - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.error_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() assert mock_inverter_data.call_count == 1 mock_inverter_data.reset_mock() mock_inverter_data.side_effect = None # next successful request resets to default interval - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.error_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() @@ -70,10 +66,8 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = BadStatusError("mock_endpoint", 8) # first 3 requests at default interval - 4th has different interval for _ in range(3): - async_fire_time_changed( - hass, - dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass, None) await hass.async_block_till_done() # BadStatusError does 3 silent retries for inverter endpoint * 3 request intervals = 9 assert mock_inverter_data.call_count == 9 diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 47b6410a146..c2e0c4ad969 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -1,4 +1,7 @@ """Tests for the Fronius sensor platform.""" + +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.fronius.const import DOMAIN from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, @@ -8,7 +11,6 @@ from homeassistant.components.fronius.coordinator import ( from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util import dt as dt_util from . import enable_all_entities, mock_responses, setup_fronius_integration @@ -17,7 +19,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_symo_inverter( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Symo inverter entities.""" @@ -31,7 +35,10 @@ async def test_symo_inverter( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 await enable_all_entities( - hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusInverterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 assert_state("sensor.symo_20_dc_current", 0) @@ -42,13 +49,15 @@ async def test_symo_inverter( # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 await enable_all_entities( - hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusInverterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # 4 additional AC entities @@ -64,9 +73,8 @@ async def test_symo_inverter( # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval - ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert_state("sensor.symo_20_ac_current", 0) assert_state("sensor.symo_20_frequency", 0) @@ -94,7 +102,9 @@ async def test_symo_logger( async def test_symo_meter( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Symo meter entities.""" @@ -108,7 +118,10 @@ async def test_symo_meter( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 24 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 58 # states are rounded to 4 decimals @@ -147,10 +160,12 @@ async def test_symo_meter( async def test_symo_power_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Symo power flow entities.""" - async_fire_time_changed(hass, dt_util.utcnow()) + async_fire_time_changed(hass) def assert_state(entity_id, expected_state): state = hass.states.get(entity_id) @@ -162,7 +177,10 @@ async def test_symo_power_flow( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 20 await enable_all_entities( - hass, config_entry.entry_id, FroniusInverterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusInverterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 # states are rounded to 4 decimals @@ -175,9 +193,8 @@ async def test_symo_power_flow( # Second test at daytime when inverter is producing mock_responses(aioclient_mock, night=False) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval - ) + freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 @@ -192,9 +209,8 @@ async def test_symo_power_flow( # Third test at nighttime - default values are used mock_responses(aioclient_mock, night=True) - async_fire_time_changed( - hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval - ) + freezer.tick(FroniusPowerFlowUpdateCoordinator.default_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 assert_state("sensor.solarnet_energy_day", 10828) @@ -207,7 +223,11 @@ async def test_symo_power_flow( assert_state("sensor.solarnet_relative_self_consumption", 0) -async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_gen24( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: """Test Fronius Gen24 inverter entities.""" def assert_state(entity_id, expected_state): @@ -220,7 +240,10 @@ async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 22 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 52 # inverter 1 @@ -281,7 +304,9 @@ async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - async def test_gen24_storage( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" @@ -297,7 +322,10 @@ async def test_gen24_storage( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 34 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 64 # inverter 1 @@ -405,7 +433,9 @@ async def test_gen24_storage( async def test_primo_s0( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Primo dual inverter with S0 meter entities.""" @@ -419,7 +449,10 @@ async def test_primo_s0( assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 29 await enable_all_entities( - hass, config_entry.entry_id, FroniusMeterUpdateCoordinator.default_interval + hass, + freezer, + config_entry.entry_id, + FroniusMeterUpdateCoordinator.default_interval, ) assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 40 # logger diff --git a/tests/components/fully_kiosk/test_binary_sensor.py b/tests/components/fully_kiosk/test_binary_sensor.py index 5b88854b020..db37139b0ba 100644 --- a/tests/components/fully_kiosk/test_binary_sensor.py +++ b/tests/components/fully_kiosk/test_binary_sensor.py @@ -1,6 +1,7 @@ """Test the Fully Kiosk Browser binary sensors.""" from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from fullykiosk import FullyKioskError from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -15,13 +16,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed async def test_binary_sensors( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: @@ -76,7 +77,8 @@ async def test_binary_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") @@ -85,7 +87,8 @@ async def test_binary_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") diff --git a/tests/components/fully_kiosk/test_sensor.py b/tests/components/fully_kiosk/test_sensor.py index cc8b30640b5..05fd002a205 100644 --- a/tests/components/fully_kiosk/test_sensor.py +++ b/tests/components/fully_kiosk/test_sensor.py @@ -1,6 +1,7 @@ """Test the Fully Kiosk Browser sensors.""" from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from fullykiosk import FullyKioskError from homeassistant.components.fully_kiosk.const import DOMAIN, UPDATE_INTERVAL @@ -25,6 +26,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_sensors_sensors( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_fully_kiosk: MagicMock, init_integration: MockConfigEntry, ) -> None: @@ -141,7 +143,8 @@ async def test_sensors_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") @@ -150,7 +153,8 @@ async def test_sensors_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") diff --git a/tests/components/gardena_bluetooth/__init__.py b/tests/components/gardena_bluetooth/__init__.py index 7de0780e129..5124daa7659 100644 --- a/tests/components/gardena_bluetooth/__init__.py +++ b/tests/components/gardena_bluetooth/__init__.py @@ -7,9 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry -from tests.components.bluetooth import ( - inject_bluetooth_service_info, -) +from tests.components.bluetooth import inject_bluetooth_service_info WATER_TIMER_SERVICE_INFO = BluetoothServiceInfo( name="Timer", diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index 98ae41d195b..9395d8570e6 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable, Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from gardena_bluetooth.client import Client from gardena_bluetooth.const import DeviceInformation from gardena_bluetooth.exceptions import CharacteristicNotFound @@ -49,19 +49,19 @@ def mock_read_char_raw(): @pytest.fixture async def scan_step( - hass: HomeAssistant, + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> Generator[None, None, Callable[[], Awaitable[None]]]: """Step system time forward.""" - with freeze_time("2023-01-01", tz_offset=1) as frozen_time: + freezer.move_to("2023-01-01T01:00:00Z") - async def delay(): - """Trigger delay in system.""" - frozen_time.tick(delta=SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + async def delay(): + """Trigger delay in system.""" + freezer.tick(delta=SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() - yield delay + return delay @pytest.fixture(autouse=True) diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index fde70b60a01..31925e2d626 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -3,12 +3,13 @@ FlowResultSnapshot({ 'data_schema': None, 'description_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'errors': None, 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -19,7 +20,7 @@ 'confirm_only': True, 'source': 'bluetooth', 'title_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'unique_id': '00000000-0000-0000-0000-000000000001', }), @@ -44,11 +45,11 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'bluetooth', - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'type': , 'version': 1, }) @@ -124,7 +125,7 @@ 'options': list([ tuple( '00000000-0000-0000-0000-000000000001', - 'Timer', + 'Gardena Water Computer', ), ]), 'required': True, @@ -136,6 +137,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'user', 'type': , }) @@ -144,12 +146,13 @@ FlowResultSnapshot({ 'data_schema': None, 'description_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'errors': None, 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -182,11 +185,11 @@ 'options': list([ tuple( '00000000-0000-0000-0000-000000000001', - 'Timer', + 'Gardena Water Computer', ), tuple( '00000000-0000-0000-0000-000000000002', - 'Gardena Device', + 'Gardena Water Computer', ), ]), 'required': True, @@ -198,6 +201,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'user', 'type': , }) @@ -206,12 +210,13 @@ FlowResultSnapshot({ 'data_schema': None, 'description_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'errors': None, 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -222,7 +227,7 @@ 'confirm_only': True, 'source': 'user', 'title_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'unique_id': '00000000-0000-0000-0000-000000000001', }), @@ -247,11 +252,11 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'type': , 'version': 1, }) diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index 0c464f7cbc1..0b39525dc82 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -67,6 +67,40 @@ 'state': 'unavailable', }) # --- +# name: test_connected_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sensor threshold', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_sensor_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_connected_state.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Sensor threshold', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_title_sensor_threshold', + 'last_changed': , + 'last_updated': , + 'state': '45.0', + }) +# --- # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 8df37b40abc..1c33e8ebab9 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -1,4 +1,34 @@ # serializer version: 1 +# name: test_connected_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Title Sensor battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_sensor_battery', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_connected_state.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Title Sensor battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_sensor_battery', + 'last_changed': , + 'last_updated': , + 'state': '45', + }) +# --- # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/gardena_bluetooth/test_button.py b/tests/components/gardena_bluetooth/test_button.py index 52fa3d4b00e..480f0c3572e 100644 --- a/tests/components/gardena_bluetooth/test_button.py +++ b/tests/components/gardena_bluetooth/test_button.py @@ -9,10 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ( - ATTR_ENTITY_ID, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from . import setup_entry diff --git a/tests/components/gardena_bluetooth/test_config_flow.py b/tests/components/gardena_bluetooth/test_config_flow.py index 0f0e297c4d7..d533d1ff2da 100644 --- a/tests/components/gardena_bluetooth/test_config_flow.py +++ b/tests/components/gardena_bluetooth/test_config_flow.py @@ -17,9 +17,7 @@ from . import ( WATER_TIMER_UNNAMED_SERVICE_INFO, ) -from tests.components.bluetooth import ( - inject_bluetooth_service_info, -) +from tests.components.bluetooth import inject_bluetooth_service_info pytestmark = pytest.mark.usefixtures("mock_setup_entry") diff --git a/tests/components/gardena_bluetooth/test_number.py b/tests/components/gardena_bluetooth/test_number.py index 3b04d0cc818..ce2d19b8c63 100644 --- a/tests/components/gardena_bluetooth/test_number.py +++ b/tests/components/gardena_bluetooth/test_number.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import Mock, call -from gardena_bluetooth.const import Valve +from gardena_bluetooth.const import Sensor, Valve from gardena_bluetooth.exceptions import ( CharacteristicNoAccess, GardenaBluetoothException, @@ -19,10 +19,7 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from . import setup_entry @@ -152,3 +149,28 @@ async def test_bluetooth_error_unavailable( await scan_step() assert hass.states.get("number.mock_title_remaining_open_time") == snapshot assert hass.states.get("number.mock_title_manual_watering_time") == snapshot + + +async def test_connected_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Verify that a connectivity error makes all entities unavailable.""" + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + False + ) + mock_read_char_raw[Sensor.threshold.uuid] = Sensor.threshold.encode(45) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + assert hass.states.get("number.mock_title_sensor_threshold") == snapshot + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + True + ) + + await scan_step() + assert hass.states.get("number.mock_title_sensor_threshold") == snapshot diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py index 307a9467f00..dc0d0cb4809 100644 --- a/tests/components/gardena_bluetooth/test_sensor.py +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -1,7 +1,7 @@ """Test Gardena Bluetooth sensor.""" from collections.abc import Awaitable, Callable -from gardena_bluetooth.const import Battery, Valve +from gardena_bluetooth.const import Battery, Sensor, Valve import pytest from syrupy.assertion import SnapshotAssertion @@ -52,3 +52,28 @@ async def test_setup( mock_read_char_raw[uuid] = char_raw await scan_step() assert hass.states.get(entity_id) == snapshot + + +async def test_connected_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Verify that a connectivity error makes all entities unavailable.""" + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + False + ) + mock_read_char_raw[Sensor.battery_level.uuid] = Sensor.battery_level.encode(45) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + assert hass.states.get("sensor.mock_title_sensor_battery") == snapshot + + mock_read_char_raw[Sensor.connected_state.uuid] = Sensor.connected_state.encode( + True + ) + + await scan_step() + assert hass.states.get("sensor.mock_title_sensor_battery") == snapshot diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index e83966c0912..f7f7c390e0d 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -27,7 +27,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import AsyncMock, Mock, MockConfigEntry +from tests.common import Mock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -503,51 +503,6 @@ async def test_timeout_cancelled( assert await resp.read() == fakeimgbytes_png -async def test_no_still_image_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test that the component can grab images from stream with no still_image_url.""" - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - }, - }, - ) - await hass.async_block_till_done() - - client = await hass_client() - - with patch( - "homeassistant.components.generic.camera.GenericCamera.stream_source", - return_value=None, - ) as mock_stream_source: - # First test when there is no stream_source should fail - resp = await client.get("/api/camera_proxy/camera.config_test") - await hass.async_block_till_done() - mock_stream_source.assert_called_once() - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR - - with patch("homeassistant.components.camera.create_stream") as mock_create_stream: - # Now test when creating the stream succeeds - mock_stream = Mock() - mock_stream.async_get_image = AsyncMock() - mock_stream.async_get_image.return_value = b"stream_keyframe_image" - mock_create_stream.return_value = mock_stream - - # should start the stream and get the image - resp = await client.get("/api/camera_proxy/camera.config_test") - await hass.async_block_till_done() - mock_create_stream.assert_called_once() - mock_stream.async_get_image.assert_called_once() - assert resp.status == HTTPStatus.OK - assert await resp.read() == b"stream_keyframe_image" - - async def test_frame_interval_property(hass: HomeAssistant) -> None: """Test that the frame interval is calculated and returned correctly.""" diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 54a9c5c0796..c4d11d4af22 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -9,7 +9,7 @@ import httpx import pytest import respx -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components.camera import async_get_image from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( @@ -35,6 +35,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry @@ -79,7 +80,7 @@ async def test_form( user_flow["flow_id"], TESTDATA, ) - assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" client = await hass_client() preview_id = result1["flow_id"] @@ -92,7 +93,7 @@ async def test_form( user_input={CONF_CONFIRMED_OK: True}, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", @@ -133,13 +134,13 @@ async def test_form_only_stillimage( data, ) await hass.async_block_till_done() - assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", @@ -166,13 +167,13 @@ async def test_form_reject_still_preview( user_flow["flow_id"], TESTDATA, ) - assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: False}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "user" @@ -193,7 +194,7 @@ async def test_form_still_preview_cam_off( user_flow["flow_id"], TESTDATA, ) - assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" preview_id = result1["flow_id"] # Try to view the image, should be unavailable. @@ -214,14 +215,14 @@ async def test_form_only_stillimage_gif( user_flow["flow_id"], data, ) - assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @@ -239,14 +240,14 @@ async def test_form_only_svg_whitespace( user_flow["flow_id"], data, ) - assert result1["type"] == data_entry_flow.FlowResultType.FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY @respx.mock @@ -274,14 +275,14 @@ async def test_form_only_still_sample( user_flow["flow_id"], data, ) - assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY @respx.mock @@ -362,13 +363,13 @@ async def test_form_rtsp_mode( result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data ) - assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", @@ -399,14 +400,14 @@ async def test_form_only_stream( user_flow["flow_id"], data, ) - assert result1["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result1["type"] == FlowResultType.FORM assert result1["step_id"] == "user_confirm_still" result3 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "127_0_0_1" assert result3["options"] == { CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, @@ -422,7 +423,7 @@ async def test_form_only_stream( await hass.async_block_till_done() with patch( - "homeassistant.components.generic.camera.GenericCamera.async_camera_image", + "homeassistant.components.camera._async_get_stream_image", return_value=fakeimgbytes_jpg, ): image_obj = await async_get_image(hass, "camera.127_0_0_1") @@ -442,7 +443,7 @@ async def test_form_still_and_stream_not_provided( CONF_VERIFY_SSL: False, }, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "no_still_image_or_stream_url"} @@ -638,7 +639,7 @@ async def test_options_template_error( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" # try updating the still image url @@ -649,16 +650,16 @@ async def test_options_template_error( result["flow_id"], user_input=data, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "confirm_still" result2a = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} ) - assert result2a["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2a["type"] == FlowResultType.CREATE_ENTRY result3 = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["type"] == FlowResultType.FORM assert result3["step_id"] == "init" # verify that an invalid template reports the correct UI error. @@ -667,7 +668,7 @@ async def test_options_template_error( result3["flow_id"], user_input=data, ) - assert result4.get("type") == data_entry_flow.FlowResultType.FORM + assert result4.get("type") == FlowResultType.FORM assert result4["errors"] == {"still_image_url": "template_error"} # verify that an invalid template reports the correct UI error. @@ -678,7 +679,7 @@ async def test_options_template_error( user_input=data, ) - assert result5.get("type") == data_entry_flow.FlowResultType.FORM + assert result5.get("type") == FlowResultType.FORM assert result5["errors"] == {"stream_source": "template_error"} # verify that an relative stream url is rejected. @@ -688,7 +689,7 @@ async def test_options_template_error( result5["flow_id"], user_input=data, ) - assert result6.get("type") == data_entry_flow.FlowResultType.FORM + assert result6.get("type") == FlowResultType.FORM assert result6["errors"] == {"stream_source": "relative_url"} # verify that an malformed stream url is rejected. @@ -698,7 +699,7 @@ async def test_options_template_error( result6["flow_id"], user_input=data, ) - assert result7.get("type") == data_entry_flow.FlowResultType.FORM + assert result7.get("type") == FlowResultType.FORM assert result7["errors"] == {"stream_source": "malformed_url"} @@ -736,7 +737,7 @@ async def test_options_only_stream( await hass.async_block_till_done() result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" # try updating the config options @@ -745,13 +746,13 @@ async def test_options_only_stream( result["flow_id"], user_input=data, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "confirm_still" result3 = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} ) - assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" @@ -766,7 +767,7 @@ async def test_import(hass: HomeAssistant, fakeimg_png) -> None: result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Yaml Defined Name" await hass.async_block_till_done() @@ -778,7 +779,7 @@ async def test_import(hass: HomeAssistant, fakeimg_png) -> None: # Any name defined in yaml should end up as the entity id. assert hass.states.get("camera.yaml_defined_name") - assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["type"] == FlowResultType.ABORT # These above can be deleted after deprecation period is finished. @@ -873,7 +874,7 @@ async def test_use_wallclock_as_timestamps_option( result = await hass.config_entries.options.async_init( mock_entry.entry_id, context={"show_advanced_options": True} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" with patch( "homeassistant.components.generic.async_setup_entry", return_value=True @@ -882,12 +883,12 @@ async def test_use_wallclock_as_timestamps_option( result["flow_id"], user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["type"] == FlowResultType.FORM # Test what happens if user rejects the preview result3 = await hass.config_entries.options.async_configure( result2["flow_id"], user_input={CONF_CONFIRMED_OK: False} ) - assert result3["type"] == data_entry_flow.FlowResultType.FORM + assert result3["type"] == FlowResultType.FORM assert result3["step_id"] == "init" with patch( "homeassistant.components.generic.async_setup_entry", return_value=True @@ -896,10 +897,10 @@ async def test_use_wallclock_as_timestamps_option( result3["flow_id"], user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) - assert result4["type"] == data_entry_flow.FlowResultType.FORM + assert result4["type"] == FlowResultType.FORM assert result4["step_id"] == "confirm_still" result5 = await hass.config_entries.options.async_configure( result4["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) - assert result5["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result5["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py index 440e8c76086..765f7c11482 100644 --- a/tests/components/geo_json_events/test_config_flow.py +++ b/tests/components/geo_json_events/test_config_flow.py @@ -3,7 +3,7 @@ from datetime import timedelta import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components.geo_json_events import DOMAIN from homeassistant.const import ( CONF_LATITUDE, @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_URL, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry from tests.components.geo_json_events.conftest import URL @@ -31,7 +32,7 @@ async def test_duplicate_error_user( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,7 +45,7 @@ async def test_duplicate_error_user( }, }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -64,7 +65,7 @@ async def test_duplicate_error_import( CONF_RADIUS: 25, }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -82,7 +83,7 @@ async def test_step_import(hass: HomeAssistant) -> None: CONF_SCAN_INTERVAL: timedelta(minutes=4), }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert ( result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" ) @@ -100,7 +101,7 @@ async def test_step_user(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -113,7 +114,7 @@ async def test_step_user(hass: HomeAssistant) -> None: }, }, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert ( result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" ) diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 6c39ee35303..946cceac786 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -21,6 +21,7 @@ async def init_integration( title="Home", unique_id="123", data={"station_id": 123, "name": "Home"}, + entry_id="86129426118ae32020417a53712d6eef", ) indexes = json.loads(load_fixture("gios/indexes.json")) diff --git a/tests/components/gios/fixtures/diagnostics_data.json b/tests/components/gios/fixtures/diagnostics_data.json deleted file mode 100644 index feee534ec31..00000000000 --- a/tests/components/gios/fixtures/diagnostics_data.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "aqi": { - "name": "AQI", - "id": null, - "index": null, - "value": "good" - }, - "c6h6": { - "name": "benzene", - "id": 658, - "index": "very_good", - "value": 0.23789 - }, - "co": { - "name": "carbon monoxide", - "id": 660, - "index": "good", - "value": 251.874 - }, - "no2": { - "name": "nitrogen dioxide", - "id": 665, - "index": "good", - "value": 7.13411 - }, - "o3": { - "name": "ozone", - "id": 667, - "index": "good", - "value": 95.7768 - }, - "pm10": { - "name": "particulate matter 10", - "id": 14395, - "index": "good", - "value": 16.8344 - }, - "pm25": { - "name": "particulate matter 2.5", - "id": 670, - "index": "good", - "value": 4 - }, - "so2": { - "name": "sulfur dioxide", - "id": 672, - "index": "very_good", - "value": 4.35478 - } -} diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..67691602fcf --- /dev/null +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -0,0 +1,72 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'name': 'Home', + 'station_id': 123, + }), + 'disabled_by': None, + 'domain': 'gios', + 'entry_id': '86129426118ae32020417a53712d6eef', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Home', + 'unique_id': '123', + 'version': 1, + }), + 'coordinator_data': dict({ + 'aqi': dict({ + 'id': None, + 'index': None, + 'name': 'AQI', + 'value': 'good', + }), + 'c6h6': dict({ + 'id': 658, + 'index': 'very_good', + 'name': 'benzene', + 'value': 0.23789, + }), + 'co': dict({ + 'id': 660, + 'index': 'good', + 'name': 'carbon monoxide', + 'value': 251.874, + }), + 'no2': dict({ + 'id': 665, + 'index': 'good', + 'name': 'nitrogen dioxide', + 'value': 7.13411, + }), + 'o3': dict({ + 'id': 667, + 'index': 'good', + 'name': 'ozone', + 'value': 95.7768, + }), + 'pm10': dict({ + 'id': 14395, + 'index': 'good', + 'name': 'particulate matter 10', + 'value': 16.8344, + }), + 'pm25': dict({ + 'id': 670, + 'index': 'good', + 'name': 'particulate matter 2.5', + 'value': 4, + }), + 'so2': dict({ + 'id': 672, + 'index': 'very_good', + 'name': 'sulfur dioxide', + 'value': 4.35478, + }), + }), + }) +# --- diff --git a/tests/components/gios/test_diagnostics.py b/tests/components/gios/test_diagnostics.py index 0b9560a96e1..903de4872a2 100644 --- a/tests/components/gios/test_diagnostics.py +++ b/tests/components/gios/test_diagnostics.py @@ -1,39 +1,21 @@ """Test GIOS diagnostics.""" -import json + +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass) - coordinator_data = json.loads(load_fixture("diagnostics_data.json", "gios")) - - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - assert result["config_entry"] == { - "entry_id": entry.entry_id, - "version": 1, - "domain": "gios", - "title": "Home", - "data": { - "station_id": 123, - "name": "Home", - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": "123", - "disabled_by": None, - } - assert result["coordinator_data"] == coordinator_data + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 9516da8e841..57e542e8a21 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -20,6 +20,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.components.google import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -138,9 +139,7 @@ def token_scopes() -> list[str]: def token_expiry() -> datetime.datetime: """Expiration time for credentials used in the test.""" # OAuth library returns an offset-naive timestamp - return datetime.datetime.fromtimestamp( - datetime.datetime.utcnow().timestamp() - ) + datetime.timedelta(hours=1) + return dt_util.utcnow().replace(tzinfo=None) + datetime.timedelta(hours=1) @pytest.fixture diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8d425ae0648 --- /dev/null +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -0,0 +1,110 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'project_id': '1234', + }), + 'disabled_by': None, + 'domain': 'google_assistant', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'import', + 'title': '1234', + 'unique_id': '1234', + 'version': 1, + }), + 'query': dict({ + 'devices': dict({ + 'switch.ac': dict({ + 'on': False, + 'online': True, + }), + 'switch.decorative_lights': dict({ + 'on': True, + 'online': True, + }), + }), + }), + 'sync': dict({ + 'agentUserId': '**REDACTED**', + 'devices': list([ + dict({ + 'attributes': dict({ + 'commandOnlyOnOff': True, + }), + 'customData': dict({ + 'httpPort': 8123, + 'uuid': '**REDACTED**', + 'webhookId': None, + }), + 'id': 'switch.decorative_lights', + 'name': dict({ + 'name': 'Decorative Lights', + }), + 'otherDeviceIds': list([ + dict({ + 'deviceId': 'switch.decorative_lights', + }), + ]), + 'traits': list([ + 'action.devices.traits.OnOff', + ]), + 'type': 'action.devices.types.SWITCH', + 'willReportState': False, + }), + dict({ + 'attributes': dict({ + }), + 'customData': dict({ + 'httpPort': 8123, + 'uuid': '**REDACTED**', + 'webhookId': None, + }), + 'id': 'switch.ac', + 'name': dict({ + 'name': 'AC', + }), + 'otherDeviceIds': list([ + dict({ + 'deviceId': 'switch.ac', + }), + ]), + 'traits': list([ + 'action.devices.traits.OnOff', + ]), + 'type': 'action.devices.types.OUTLET', + 'willReportState': False, + }), + ]), + }), + 'yaml_config': dict({ + 'expose_by_default': True, + 'exposed_domains': list([ + 'alarm_control_panel', + 'binary_sensor', + 'climate', + 'cover', + 'fan', + 'group', + 'humidifier', + 'input_boolean', + 'input_select', + 'light', + 'lock', + 'media_player', + 'scene', + 'script', + 'select', + 'sensor', + 'switch', + 'vacuum', + ]), + 'project_id': '1234', + 'report_state': False, + 'service_account': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py index fde7e99025f..df8221b5053 100644 --- a/tests/components/google_assistant/test_diagnostics.py +++ b/tests/components/google_assistant/test_diagnostics.py @@ -1,7 +1,9 @@ """Test diagnostics.""" -from unittest.mock import ANY, patch +from unittest.mock import patch import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props from homeassistant import setup from homeassistant.components import google_assistant as ga, switch @@ -26,7 +28,9 @@ async def switch_only() -> None: async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics v1.""" @@ -42,84 +46,6 @@ async def test_diagnostics( ) config_entry = hass.config_entries.async_entries("google_assistant")[0] - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert result == { - "config_entry": { - "data": {"project_id": "1234"}, - "disabled_by": None, - "domain": "google_assistant", - "entry_id": ANY, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "import", - "title": "1234", - "unique_id": "1234", - "version": 1, - }, - "sync": { - "agentUserId": "**REDACTED**", - "devices": [ - { - "attributes": {"commandOnlyOnOff": True}, - "id": "switch.decorative_lights", - "otherDeviceIds": [{"deviceId": "switch.decorative_lights"}], - "name": {"name": "Decorative Lights"}, - "traits": ["action.devices.traits.OnOff"], - "type": "action.devices.types.SWITCH", - "willReportState": False, - "customData": { - "httpPort": 8123, - "uuid": "**REDACTED**", - "webhookId": None, - }, - }, - { - "attributes": {}, - "id": "switch.ac", - "otherDeviceIds": [{"deviceId": "switch.ac"}], - "name": {"name": "AC"}, - "traits": ["action.devices.traits.OnOff"], - "type": "action.devices.types.OUTLET", - "willReportState": False, - "customData": { - "httpPort": 8123, - "uuid": "**REDACTED**", - "webhookId": None, - }, - }, - ], - }, - "query": { - "devices": { - "switch.ac": {"on": False, "online": True}, - "switch.decorative_lights": {"on": True, "online": True}, - } - }, - "yaml_config": { - "expose_by_default": True, - "exposed_domains": [ - "alarm_control_panel", - "binary_sensor", - "climate", - "cover", - "fan", - "group", - "humidifier", - "input_boolean", - "input_select", - "light", - "lock", - "media_player", - "scene", - "script", - "select", - "sensor", - "switch", - "vacuum", - ], - "project_id": "1234", - "report_state": False, - "service_account": "**REDACTED**", - }, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("entry_id")) diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index b7dc880ede0..44dc40f5a47 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -1,5 +1,5 @@ """Test Google http services.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from http import HTTPStatus from typing import Any from unittest.mock import ANY, patch @@ -51,7 +51,7 @@ async def test_get_jwt(hass: HomeAssistant) -> None: jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkdW1teUBkdW1teS5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsInNjb3BlIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9ob21lZ3JhcGgiLCJhdWQiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9vYXV0aDIvdG9rZW4iLCJpYXQiOjE1NzEwMTEyMDAsImV4cCI6MTU3MTAxNDgwMH0.akHbMhOflXdIDHVvUVwO0AoJONVOPUdCghN6hAdVz4gxjarrQeGYc_Qn2r84bEvCU7t6EvimKKr0fyupyzBAzfvKULs5mTHO3h2CwSgvOBMv8LnILboJmbO4JcgdnRV7d9G3ktQs7wWSCXJsI5i5jUr1Wfi9zWwxn2ebaAAgrp8" res = _get_homegraph_jwt( - datetime(2019, 10, 14, tzinfo=timezone.utc), + datetime(2019, 10, 14, tzinfo=UTC), DUMMY_CONFIG["service_account"]["client_email"], DUMMY_CONFIG["service_account"]["private_key"], ) @@ -85,7 +85,7 @@ async def test_update_access_token(hass: HomeAssistant) -> None: config = GoogleConfig(hass, DUMMY_CONFIG) await config.async_initialize() - base_time = datetime(2019, 10, 14, tzinfo=timezone.utc) + base_time = datetime(2019, 10, 14, tzinfo=UTC) with patch( "homeassistant.components.google_assistant.http._get_homegraph_token" ) as mock_get_token, patch( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index f471e6f862c..bf48564c251 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -6,7 +6,7 @@ from unittest.mock import ANY, patch import pytest from pytest_unordered import unordered -from homeassistant.components import camera +from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.climate import ATTR_MAX_TEMP, ATTR_MIN_TEMP, HVACMode from homeassistant.components.demo.binary_sensor import DemoBinarySensor from homeassistant.components.demo.cover import DemoCover @@ -39,7 +39,7 @@ from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig -from tests.common import async_capture_events +from tests.common import MockConfigEntry, async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @@ -251,10 +251,12 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: @pytest.mark.parametrize("area_on_device", [True, False]) async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> None: """Test a sync message where room hint comes from area.""" + entry = MockConfigEntry() + entry.add_to_hass(hass) area = registries.area.async_create("Living Room") device = registries.device.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, manufacturer="Someone", model="Some model", sw_version="Some Version", @@ -625,8 +627,8 @@ async def test_execute_times_out( """Test an execute command which times out.""" orig_execute_limit = sh.EXECUTE_LIMIT sh.EXECUTE_LIMIT = 0.02 # Decrease timeout to 20ms - await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, "light", {"light": {"platform": "demo"}}) await hass.async_block_till_done() await hass.services.async_call( @@ -1184,7 +1186,9 @@ async def test_trait_execute_adding_query_data(hass: HomeAssistant) -> None: {"external_url": "https://example.com"}, ) hass.states.async_set( - "camera.office", "idle", {"supported_features": camera.SUPPORT_STREAM} + "camera.office", + "idle", + {"supported_features": CameraEntityFeature.STREAM}, ) with patch( diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index fd6b3a6790b..fcbf16c21c7 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -27,9 +27,22 @@ from homeassistant.components import ( switch, vacuum, ) +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature +from homeassistant.components.camera import CameraEntityFeature +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.fan import FanEntityFeature from homeassistant.components.google_assistant import const, error, helpers, trait from homeassistant.components.google_assistant.error import SmartHomeError -from homeassistant.components.media_player import SERVICE_PLAY_MEDIA, MediaType +from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.light import LightEntityFeature +from homeassistant.components.lock import LockEntityFeature +from homeassistant.components.media_player import ( + SERVICE_PLAY_MEDIA, + MediaPlayerEntityFeature, + MediaType, +) +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -126,7 +139,7 @@ async def test_camera_stream(hass: HomeAssistant) -> None: ) assert helpers.get_google_type(camera.DOMAIN, None) is not None assert trait.CameraStreamTrait.supported( - camera.DOMAIN, camera.SUPPORT_STREAM, None, None + camera.DOMAIN, CameraEntityFeature.STREAM, None, None ) trt = trait.CameraStreamTrait( @@ -364,7 +377,7 @@ async def test_locate_vacuum(hass: HomeAssistant) -> None: """Test locate trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.LocatorTrait.supported( - vacuum.DOMAIN, vacuum.SUPPORT_LOCATE, None, None + vacuum.DOMAIN, VacuumEntityFeature.LOCATE, None, None ) trt = trait.LocatorTrait( @@ -372,7 +385,7 @@ async def test_locate_vacuum(hass: HomeAssistant) -> None: State( "vacuum.bla", vacuum.STATE_IDLE, - {ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_LOCATE}, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.LOCATE}, ), BASIC_CONFIG, ) @@ -395,7 +408,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: """Test EnergyStorage trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None assert trait.EnergyStorageTrait.supported( - vacuum.DOMAIN, vacuum.SUPPORT_BATTERY, None, None + vacuum.DOMAIN, VacuumEntityFeature.BATTERY, None, None ) trt = trait.EnergyStorageTrait( @@ -404,7 +417,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: "vacuum.bla", vacuum.STATE_DOCKED, { - ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 100, }, ), @@ -430,7 +443,7 @@ async def test_energystorage_vacuum(hass: HomeAssistant) -> None: "vacuum.bla", vacuum.STATE_CLEANING, { - ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_BATTERY, + ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.BATTERY, ATTR_BATTERY_LEVEL: 20, }, ), @@ -469,7 +482,7 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: State( "vacuum.bla", vacuum.STATE_PAUSED, - {ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_PAUSE}, + {ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.PAUSE}, ), BASIC_CONFIG, ) @@ -502,12 +515,14 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: async def test_startstop_cover(hass: HomeAssistant) -> None: """Test startStop trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.StartStopTrait.supported(cover.DOMAIN, cover.SUPPORT_STOP, None, None) + assert trait.StartStopTrait.supported( + cover.DOMAIN, CoverEntityFeature.STOP, None, None + ) state = State( "cover.bla", cover.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_STOP}, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP}, ) trt = trait.StartStopTrait( @@ -551,7 +566,10 @@ async def test_startstop_cover_assumed(hass: HomeAssistant) -> None: State( "cover.bla", cover.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_STOP, ATTR_ASSUMED_STATE: True}, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP, + ATTR_ASSUMED_STATE: True, + }, ), BASIC_CONFIG, ) @@ -707,7 +725,9 @@ async def test_color_light_temperature_light_bad_temp(hass: HomeAssistant) -> No async def test_light_modes(hass: HomeAssistant) -> None: """Test Light Mode trait.""" assert helpers.get_google_type(light.DOMAIN, None) is not None - assert trait.ModesTrait.supported(light.DOMAIN, light.SUPPORT_EFFECT, None, None) + assert trait.ModesTrait.supported( + light.DOMAIN, LightEntityFeature.EFFECT, None, None + ) trt = trait.ModesTrait( hass, @@ -847,7 +867,7 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: "climate.bla", climate.HVACMode.AUTO, { - ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, climate.ATTR_HVAC_MODES: [ climate.HVACMode.OFF, climate.HVACMode.COOL, @@ -928,7 +948,7 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: { climate.ATTR_CURRENT_TEMPERATURE: 70, climate.ATTR_CURRENT_HUMIDITY: 25, - ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, climate.ATTR_HVAC_MODES: [ STATE_OFF, climate.HVACMode.COOL, @@ -1040,7 +1060,7 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None "climate.bla", climate.HVACMode.COOL, { - ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE, + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, climate.ATTR_HVAC_MODES: [STATE_OFF, climate.HVACMode.COOL], climate.ATTR_MIN_TEMP: 10, climate.ATTR_MAX_TEMP: 30, @@ -1230,8 +1250,10 @@ async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None async def test_lock_unlock_lock(hass: HomeAssistant) -> None: """Test LockUnlock trait locking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) - assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_LOCKED), PIN_CONFIG @@ -1254,8 +1276,10 @@ async def test_lock_unlock_lock(hass: HomeAssistant) -> None: async def test_lock_unlock_unlocking(hass: HomeAssistant) -> None: """Test LockUnlock trait locking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) - assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_UNLOCKING), PIN_CONFIG @@ -1269,8 +1293,10 @@ async def test_lock_unlock_unlocking(hass: HomeAssistant) -> None: async def test_lock_unlock_lock_jammed(hass: HomeAssistant) -> None: """Test LockUnlock trait locking support for lock domain that jams.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) - assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_JAMMED), PIN_CONFIG @@ -1293,7 +1319,9 @@ async def test_lock_unlock_lock_jammed(hass: HomeAssistant) -> None: async def test_lock_unlock_unlock(hass: HomeAssistant) -> None: """Test LockUnlock trait unlocking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None - assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) + assert trait.LockUnlockTrait.supported( + lock.DOMAIN, LockEntityFeature.OPEN, None, None + ) trt = trait.LockUnlockTrait( hass, State("lock.front_door", lock.STATE_LOCKED), PIN_CONFIG @@ -1363,8 +1391,8 @@ async def test_arm_disarm_arm_away(hass: HomeAssistant) -> None: STATE_ALARM_ARMED_AWAY, { alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True, - ATTR_SUPPORTED_FEATURES: alarm_control_panel.const.SUPPORT_ALARM_ARM_HOME - | alarm_control_panel.const.SUPPORT_ALARM_ARM_AWAY, + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY, }, ), PIN_CONFIG, @@ -1526,8 +1554,8 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: STATE_ALARM_DISARMED, { alarm_control_panel.ATTR_CODE_ARM_REQUIRED: True, - ATTR_SUPPORTED_FEATURES: alarm_control_panel.const.SUPPORT_ALARM_TRIGGER - | alarm_control_panel.const.SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, }, ), PIN_CONFIG, @@ -1662,7 +1690,9 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: async def test_fan_speed(hass: HomeAssistant) -> None: """Test FanSpeed trait speed control support for fan domain.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None, None) + assert trait.FanSpeedTrait.supported( + fan.DOMAIN, FanEntityFeature.SET_SPEED, None, None + ) trt = trait.FanSpeedTrait( hass, @@ -1700,7 +1730,9 @@ async def test_fan_speed(hass: HomeAssistant) -> None: async def test_fan_speed_without_percentage_step(hass: HomeAssistant) -> None: """Test FanSpeed trait speed control percentage step for fan domain.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None, None) + assert trait.FanSpeedTrait.supported( + fan.DOMAIN, FanEntityFeature.SET_SPEED, None, None + ) trt = trait.FanSpeedTrait( hass, @@ -1787,7 +1819,9 @@ async def test_fan_speed_ordered( ): """Test FanSpeed trait speed control support for fan domain.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED, None, None) + assert trait.FanSpeedTrait.supported( + fan.DOMAIN, FanEntityFeature.SET_SPEED, None, None + ) trt = trait.FanSpeedTrait( hass, @@ -1858,7 +1892,7 @@ async def test_fan_reverse( "percentage": 33, "percentage_step": 1.0, "direction": direction_state, - "supported_features": fan.SUPPORT_DIRECTION, + "supported_features": FanEntityFeature.DIRECTION, }, ), BASIC_CONFIG, @@ -1889,7 +1923,7 @@ async def test_climate_fan_speed(hass: HomeAssistant) -> None: """Test FanSpeed trait speed control support for climate domain.""" assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.FanSpeedTrait.supported( - climate.DOMAIN, climate.SUPPORT_FAN_MODE, None, None + climate.DOMAIN, ClimateEntityFeature.FAN_MODE, None, None ) trt = trait.FanSpeedTrait( @@ -1951,7 +1985,7 @@ async def test_inputselector(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.InputSelectorTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.SELECT_SOURCE, + MediaPlayerEntityFeature.SELECT_SOURCE, None, None, ) @@ -2265,7 +2299,7 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: """Test Humidifier Mode trait.""" assert helpers.get_google_type(humidifier.DOMAIN, None) is not None assert trait.ModesTrait.supported( - humidifier.DOMAIN, humidifier.SUPPORT_MODES, None, None + humidifier.DOMAIN, HumidifierEntityFeature.MODES, None, None ) trt = trait.ModesTrait( @@ -2279,7 +2313,7 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: humidifier.MODE_AUTO, humidifier.MODE_AWAY, ], - ATTR_SUPPORTED_FEATURES: humidifier.SUPPORT_MODES, + ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES, humidifier.ATTR_MIN_HUMIDITY: 30, humidifier.ATTR_MAX_HUMIDITY: 99, humidifier.ATTR_HUMIDITY: 50, @@ -2345,7 +2379,7 @@ async def test_sound_modes(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.ModesTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.SELECT_SOUND_MODE, + MediaPlayerEntityFeature.SELECT_SOUND_MODE, None, None, ) @@ -2420,7 +2454,9 @@ async def test_sound_modes(hass: HomeAssistant) -> None: async def test_preset_modes(hass: HomeAssistant) -> None: """Test Mode trait for fan preset modes.""" assert helpers.get_google_type(fan.DOMAIN, None) is not None - assert trait.ModesTrait.supported(fan.DOMAIN, fan.SUPPORT_PRESET_MODE, None, None) + assert trait.ModesTrait.supported( + fan.DOMAIN, FanEntityFeature.PRESET_MODE, None, None + ) trt = trait.ModesTrait( hass, @@ -2430,7 +2466,7 @@ async def test_preset_modes(hass: HomeAssistant) -> None: attributes={ fan.ATTR_PRESET_MODES: ["auto", "whoosh"], fan.ATTR_PRESET_MODE: "auto", - ATTR_SUPPORTED_FEATURES: fan.SUPPORT_PRESET_MODE, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE, }, ), BASIC_CONFIG, @@ -2514,7 +2550,7 @@ async def test_openclose_cover(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, None, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None ) trt = trait.OpenCloseTrait( @@ -2524,7 +2560,7 @@ async def test_openclose_cover(hass: HomeAssistant) -> None: cover.STATE_OPEN, { cover.ATTR_CURRENT_POSITION: 75, - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), BASIC_CONFIG, @@ -2551,14 +2587,16 @@ async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain with unknown state.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, None, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None ) # No state trt = trait.OpenCloseTrait( hass, State( - "cover.bla", STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN} + "cover.bla", + STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN}, ), BASIC_CONFIG, ) @@ -2581,7 +2619,7 @@ async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, None, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None ) trt = trait.OpenCloseTrait( @@ -2591,7 +2629,7 @@ async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: cover.STATE_OPEN, { ATTR_ASSUMED_STATE: True, - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), BASIC_CONFIG, @@ -2634,14 +2672,17 @@ async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, None, None + cover.DOMAIN, + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + None, + None, ) state = State( "cover.bla", cover.STATE_OPEN, { - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, }, ) @@ -2695,10 +2736,10 @@ async def test_openclose_cover_secure(hass: HomeAssistant, device_class) -> None """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, device_class) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class, None + cover.DOMAIN, CoverEntityFeature.SET_POSITION, device_class, None ) assert trait.OpenCloseTrait.might_2fa( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class + cover.DOMAIN, CoverEntityFeature.SET_POSITION, device_class ) trt = trait.OpenCloseTrait( @@ -2708,7 +2749,7 @@ async def test_openclose_cover_secure(hass: HomeAssistant, device_class) -> None cover.STATE_OPEN, { ATTR_DEVICE_CLASS: device_class, - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, cover.ATTR_CURRENT_POSITION: 75, }, ), @@ -2796,7 +2837,7 @@ async def test_volume_media_player(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.VolumeTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.VOLUME_SET, + MediaPlayerEntityFeature.VOLUME_SET, None, None, ) @@ -2807,7 +2848,7 @@ async def test_volume_media_player(hass: HomeAssistant) -> None: "media_player.bla", media_player.STATE_PLAYING, { - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.VOLUME_SET, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.3, }, ), @@ -2850,7 +2891,7 @@ async def test_volume_media_player_relative(hass: HomeAssistant) -> None: """Test volume trait support for relative-volume-only media players.""" assert trait.VolumeTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.VOLUME_STEP, + MediaPlayerEntityFeature.VOLUME_STEP, None, None, ) @@ -2861,7 +2902,7 @@ async def test_volume_media_player_relative(hass: HomeAssistant) -> None: media_player.STATE_PLAYING, { ATTR_ASSUMED_STATE: True, - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.VOLUME_STEP, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_STEP, }, ), BASIC_CONFIG, @@ -2918,8 +2959,7 @@ async def test_media_player_mute(hass: HomeAssistant) -> None: """Test volume trait support for muting.""" assert trait.VolumeTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.VOLUME_STEP - | media_player.MediaPlayerEntityFeature.VOLUME_MUTE, + MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE, None, None, ) @@ -2930,8 +2970,8 @@ async def test_media_player_mute(hass: HomeAssistant) -> None: media_player.STATE_PLAYING, { ATTR_SUPPORTED_FEATURES: ( - media_player.MediaPlayerEntityFeature.VOLUME_STEP - | media_player.MediaPlayerEntityFeature.VOLUME_MUTE + MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE ), media_player.ATTR_MEDIA_VOLUME_MUTED: False, }, @@ -3095,8 +3135,8 @@ async def test_transport_control(hass: HomeAssistant) -> None: media_player.ATTR_MEDIA_POSITION_UPDATED_AT: now - timedelta(seconds=10), media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.5, - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.PLAY - | media_player.MediaPlayerEntityFeature.STOP, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP, }, ), BASIC_CONFIG, @@ -3210,7 +3250,7 @@ async def test_media_state(hass: HomeAssistant, state) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.TransportControlTrait.supported( - media_player.DOMAIN, media_player.MediaPlayerEntityFeature.PLAY, None, None + media_player.DOMAIN, MediaPlayerEntityFeature.PLAY, None, None ) trt = trait.MediaStateTrait( @@ -3222,8 +3262,8 @@ async def test_media_state(hass: HomeAssistant, state) -> None: media_player.ATTR_MEDIA_POSITION: 100, media_player.ATTR_MEDIA_DURATION: 200, media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.5, - ATTR_SUPPORTED_FEATURES: media_player.MediaPlayerEntityFeature.PLAY - | media_player.MediaPlayerEntityFeature.STOP, + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP, }, ), BASIC_CONFIG, @@ -3244,14 +3284,14 @@ async def test_channel(hass: HomeAssistant) -> None: assert helpers.get_google_type(media_player.DOMAIN, None) is not None assert trait.ChannelTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.PLAY_MEDIA, + MediaPlayerEntityFeature.PLAY_MEDIA, media_player.MediaPlayerDeviceClass.TV, None, ) assert ( trait.ChannelTrait.supported( media_player.DOMAIN, - media_player.MediaPlayerEntityFeature.PLAY_MEDIA, + MediaPlayerEntityFeature.PLAY_MEDIA, None, None, ) diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 3cb64a9a441..de89d562d46 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -162,20 +162,26 @@ async def test_send_text_commands( command1 = "open the garage door" command2 = "1234" + command1_response = "what's the PIN?" + command2_response = "opened the garage door" with patch( - "homeassistant.components.google_assistant_sdk.helpers.TextAssistant" - ) as mock_text_assistant: - await hass.services.async_call( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist", + side_effect=[ + (command1_response, None, None), + (command2_response, None, None), + ], + ) as mock_assist_call: + response = await hass.services.async_call( DOMAIN, "send_text_command", {"command": [command1, command2]}, blocking=True, + return_response=True, ) - mock_text_assistant.assert_called_once_with( - ExpectedCredentials(), "en-US", audio_out=False - ) - mock_text_assistant.assert_has_calls([call().__enter__().assist(command1)]) - mock_text_assistant.assert_has_calls([call().__enter__().assist(command2)]) + assert response == { + "responses": [{"text": command1_response}, {"text": command2_response}] + } + mock_assist_call.assert_has_calls([call(command1), call(command2)]) @pytest.mark.parametrize( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index e8da4cf3920..982f3993e04 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -20,11 +20,13 @@ async def test_default_prompt( snapshot: SnapshotAssertion, ) -> None: """Test that the default prompt works.""" + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) for i in range(3): area_registry.async_create(f"{i}Empty Area") device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "1234")}, name="Test Device", manufacturer="Test Manufacturer", @@ -33,7 +35,7 @@ async def test_default_prompt( ) for i in range(3): device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", f"{i}abcd")}, name="Test Service", manufacturer="Test Manufacturer", @@ -42,7 +44,7 @@ async def test_default_prompt( entry_type=dr.DeviceEntryType.SERVICE, ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "5678")}, name="Test Device 2", manufacturer="Test Manufacturer 2", @@ -50,7 +52,7 @@ async def test_default_prompt( suggested_area="Test Area 2", ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876")}, name="Test Device 3", manufacturer="Test Manufacturer 3", @@ -58,13 +60,13 @@ async def test_default_prompt( suggested_area="Test Area 2", ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "qwer")}, name="Test Device 4", suggested_area="Test Area 2", ) device = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876-disabled")}, name="Test Device 3", manufacturer="Test Manufacturer 3", @@ -75,14 +77,14 @@ async def test_default_prompt( device.id, disabled_by=dr.DeviceEntryDisabler.USER ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876-no-name")}, manufacturer="Test Manufacturer NoName", model="Test Model NoName", suggested_area="Test Area 2", ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876-integer-values")}, name=1, manufacturer=2, diff --git a/tests/components/govee_ble/__init__.py b/tests/components/govee_ble/__init__.py index 54e7c1ee777..5dd67adb160 100644 --- a/tests/components/govee_ble/__init__.py +++ b/tests/components/govee_ble/__init__.py @@ -37,6 +37,31 @@ GVH5177_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +GVH5178_REMOTE_SERVICE_INFO = BluetoothServiceInfo( + name="B51782BC8", + address="A4:C1:38:75:2B:C8", + rssi=-66, + manufacturer_data={ + 1: b"\x01\x01\x01\x00\x2a\xf7\x64\x00\x03", + 76: b"\x02\x15INTELLI_ROCKS_HWPu\xf2\xff\xc2", + }, + service_data={}, + service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], + source="local", +) +GVH5178_PRIMARY_SERVICE_INFO = BluetoothServiceInfo( + name="B51782BC8", + address="A4:C1:38:75:2B:C8", + rssi=-66, + manufacturer_data={ + 1: b"\x01\x01\x00\x00\x2a\xf7\x64\x00\x03", + 76: b"\x02\x15INTELLI_ROCKS_HWPu\xf2\xff\xc2", + }, + service_data={}, + service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"], + source="local", +) + GVH5178_SERVICE_INFO_ERROR = BluetoothServiceInfo( name="B51782BC8", address="A4:C1:38:75:2B:C8", diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index 1408a35142a..185ae2404da 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -1,4 +1,11 @@ """Test the Govee BLE sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.govee_ble.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ( @@ -7,11 +14,20 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import GVH5075_SERVICE_INFO, GVH5178_SERVICE_INFO_ERROR +from . import ( + GVH5075_SERVICE_INFO, + GVH5178_PRIMARY_SERVICE_INFO, + GVH5178_REMOTE_SERVICE_INFO, + GVH5178_SERVICE_INFO_ERROR, +) -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) async def test_sensors(hass: HomeAssistant) -> None: @@ -62,3 +78,80 @@ async def test_gvh5178_error(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: + """Test H5178 with a primary and remote sensor. + + The gateway sensor is responsible for broadcasting the state for + all sensors and it does so in many advertisements. We want + all the connected devices to stay available when the gateway + sensor is available. + """ + start_monotonic = time.monotonic() + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:75:2B:C8", + ) + 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, GVH5178_REMOTE_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + temp_sensor = hass.states.get("sensor.b51782bc8_remote_temperature") + assert temp_sensor.state == "1.0" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.b51782bc8_remote_temperature") + assert temp_sensor.state == STATE_UNAVAILABLE + + inject_bluetooth_service_info(hass, GVH5178_PRIMARY_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.b51782bc8_remote_temperature") + assert temp_sensor.state == "1.0" + + primary_temp_sensor = hass.states.get("sensor.b51782bc8_primary_temperature") + assert primary_temp_sensor.state == "1.0" + + # Fastforward time without BLE advertisements + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=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() + + temp_sensor = hass.states.get("sensor.b51782bc8_remote_temperature") + assert temp_sensor.state == STATE_UNAVAILABLE + + primary_temp_sensor = hass.states.get("sensor.b51782bc8_primary_temperature") + assert primary_temp_sensor.state == STATE_UNAVAILABLE diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 8202601fc18..1c8275c7f2d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -1,15 +1,18 @@ """Test the Switch config flow.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant import config_entries from homeassistant.components.group import DOMAIN, async_setup_entry +from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator @pytest.mark.parametrize( @@ -26,6 +29,18 @@ from tests.common import MockConfigEntry ("binary_sensor", "on", "on", {}, {}, {"all": False}, {}), ("binary_sensor", "on", "on", {}, {"all": True}, {"all": True}, {}), ("cover", "open", "open", {}, {}, {}, {}), + ( + "event", + STATE_UNKNOWN, + "2021-01-01T23:59:59.123+00:00", + { + "event_type": "single_press", + "event_types": ["single_press", "double_press"], + }, + {}, + {}, + {}, + ), ("fan", "on", "on", {}, {}, {}, {}), ("light", "on", "on", {}, {}, {}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), @@ -120,6 +135,7 @@ async def test_config_flow( ( ("binary_sensor", {"all": False}), ("cover", {}), + ("event", {}), ("fan", {}), ("light", {}), ("lock", {}), @@ -192,6 +208,7 @@ def get_suggested(schema, key): ( ("binary_sensor", "on", {"all": False}, {}), ("cover", "open", {}, {}), + ("event", "2021-01-01T23:59:59.123+00:00", {}, {}), ("fan", "on", {}, {}), ("light", "on", {"all": False}, {}), ("lock", "locked", {}, {}), @@ -375,6 +392,7 @@ async def test_all_options( ( ("binary_sensor", {"all": False}), ("cover", {}), + ("event", {}), ("fan", {}), ("light", {}), ("lock", {}), @@ -446,3 +464,235 @@ async def test_options_flow_hides_members( assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + + +COVER_ATTRS = [{"supported_features": 0}, {}] +EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] +FAN_ATTRS = [{"supported_features": 0}, {}] +LIGHT_ATTRS = [ + { + "icon": "mdi:lightbulb-group", + "supported_color_modes": ["onoff"], + "supported_features": 0, + }, + {"color_mode": "onoff"}, +] +LOCK_ATTRS = [{"supported_features": 1}, {}] +MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}] +SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}] + + +@pytest.mark.parametrize( + ("domain", "extra_user_input", "input_states", "group_state", "extra_attributes"), + [ + ("binary_sensor", {"all": True}, ["on", "off"], "off", [{}, {}]), + ("cover", {}, ["open", "closed"], "open", COVER_ATTRS), + ("event", {}, ["", ""], "unknown", EVENT_ATTRS), + ("fan", {}, ["on", "off"], "on", FAN_ATTRS), + ("light", {}, ["on", "off"], "on", LIGHT_ATTRS), + ("lock", {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("media_player", {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), + ("sensor", {"type": "max"}, ["10", "20"], "20.0", SENSOR_ATTRS), + ("switch", {}, ["on", "off"], "on", [{}, {}]), + ], +) +async def test_config_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + extra_user_input: dict[str, Any], + input_states: list[str], + group_state: str, + extra_attributes: list[dict[str, Any]], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = [f"{domain}.input_one", f"{domain}.input_two"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": domain}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == domain + assert result["errors"] is None + assert result["preview"] == "group" + + await client.send_json_auto_id( + { + "type": "group/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My group", "entities": input_entities} + | extra_user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My group"} | extra_attributes[0], + "state": "unavailable", + } + + hass.states.async_set(input_entities[0], input_states[0]) + hass.states.async_set(input_entities[1], input_states[1]) + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "entity_id": input_entities, + "friendly_name": "My group", + } + | extra_attributes[0] + | extra_attributes[1], + "state": group_state, + } + assert len(hass.states.async_all()) == 2 + + +@pytest.mark.parametrize( + ( + "domain", + "extra_config_flow_data", + "extra_user_input", + "input_states", + "group_state", + "extra_attributes", + ), + [ + ("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", [{}, {}]), + ("cover", {}, {}, ["open", "closed"], "open", COVER_ATTRS), + ("event", {}, {}, ["", ""], "unknown", EVENT_ATTRS), + ("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS), + ("light", {}, {}, ["on", "off"], "on", LIGHT_ATTRS), + ("lock", {}, {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("media_player", {}, {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), + ( + "sensor", + {"type": "min"}, + {"type": "max"}, + ["10", "20"], + "20.0", + SENSOR_ATTRS, + ), + ("switch", {}, {}, ["on", "off"], "on", [{}, {}]), + ], +) +async def test_option_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + extra_config_flow_data: dict[str, Any], + extra_user_input: dict[str, Any], + input_states: list[str], + group_state: str, + extra_attributes: dict[str, Any], +) -> None: + """Test the option flow preview.""" + input_entities = [f"{domain}.input_one", f"{domain}.input_two"] + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entities": input_entities, + "group_type": domain, + "hide_members": False, + "name": "My group", + } + | extra_config_flow_data, + title="My group", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "group" + + hass.states.async_set(input_entities[0], input_states[0]) + hass.states.async_set(input_entities[1], input_states[1]) + + await client.send_json_auto_id( + { + "type": "group/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": {"entities": input_entities} | extra_user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"entity_id": input_entities, "friendly_name": "My group"} + | extra_attributes[0] + | extra_attributes[1], + "state": group_state, + } + assert len(hass.states.async_all()) == 3 + + +async def test_option_flow_sensor_preview_config_entry_removed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + input_entities = ["sensor.input_one", "sensor.input_two"] + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entities": input_entities, + "group_type": "sensor", + "hide_members": False, + "name": "My sensor group", + "type": "min", + }, + title="My min_max", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "group" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "group/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "entities": input_entities, + "type": "min", + }, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 863747369e1..4e0ddc19a31 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -1,7 +1,7 @@ """The tests for the group cover platform.""" +import asyncio from datetime import timedelta -import async_timeout import pytest from homeassistant.components.cover import ( @@ -346,10 +346,10 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_CURRENT_POSITION] == 70 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # ### Test assumed state ### + # ### Test state when group members have different states ### # ########################## - # For covers - assumed state set true if position differ + # Covers hass.states.async_set( DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 100} ) @@ -357,7 +357,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 assert state.attributes[ATTR_CURRENT_POSITION] == 85 # (70 + 100) / 2 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 @@ -373,7 +373,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # For tilts - assumed state set true if tilt position differ + # Tilts hass.states.async_set( DEMO_TILT, STATE_OPEN, @@ -383,7 +383,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 # (60 + 100) / 2 @@ -399,11 +399,12 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes + # Group member has set assumed_state hass.states.async_set(DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True}) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes # Test entity registry integration entity_registry = er.async_get(hass) @@ -828,7 +829,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["cover.bedroom_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER, diff --git a/tests/components/group/test_event.py b/tests/components/group/test_event.py new file mode 100644 index 00000000000..16ea11fe311 --- /dev/null +++ b/tests/components/group/test_event.py @@ -0,0 +1,138 @@ +"""The tests for the group event platform.""" + +from pytest_unordered import unordered + +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN +from homeassistant.components.event.const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES +from homeassistant.components.group import DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +async def test_default_state(hass: HomeAssistant) -> None: + """Test event group default state.""" + await async_setup_component( + hass, + EVENT_DOMAIN, + { + EVENT_DOMAIN: { + "platform": DOMAIN, + "entities": ["event.button_1", "event.button_2"], + "name": "Remote control", + "unique_id": "unique_identifier", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set( + "event.button_1", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "double_press", "event_types": ["single_press", "double_press"]}, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert not state.attributes.get(ATTR_EVENT_TYPE) + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press"] + ) + + # State changed + hass.states.async_set( + "event.button_1", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "single_press", "event_types": ["single_press", "double_press"]}, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press"] + ) + + # State changed, second remote came online + hass.states.async_set( + "event.button_2", + "2021-01-01T23:59:59.123+00:00", + {"event_type": "double_press", "event_types": ["double_press", "triple_press"]}, + ) + await hass.async_block_till_done() + + # State should be single_press, because button coming online is not an event + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press", "triple_press"] + ) + + # State changed, now it fires an event + hass.states.async_set( + "event.button_2", + "2021-01-01T23:59:59.123+00:00", + { + "event_type": "triple_press", + "event_types": ["double_press", "triple_press"], + "device_class": "doorbell", + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "triple_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["single_press", "double_press", "triple_press"] + ) + assert ATTR_DEVICE_CLASS not in state.attributes + + # Mark button 1 unavailable + hass.states.async_set("event.button_1", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state + assert state.attributes.get(ATTR_ENTITY_ID) == ["event.button_1", "event.button_2"] + assert state.attributes.get(ATTR_EVENT_TYPE) == "triple_press" + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["double_press", "triple_press"] + ) + + # Mark button 2 unavailable + hass.states.async_set("event.button_2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("event.remote_control") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("event.remote_control") + assert entry + assert entry.unique_id == "unique_identifier" diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index cb980841266..2272a29f6ed 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -1,7 +1,7 @@ """The tests for the group fan platform.""" +import asyncio from unittest.mock import patch -import async_timeout import pytest from homeassistant import config as hass_config @@ -247,11 +247,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different speed should set assumed state + # Add Entity with a different speed should not set assumed state hass.states.async_set( PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, @@ -264,7 +260,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_PERCENTAGE] == int((50 + 75) / 2) @@ -306,11 +302,7 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different direction should set assumed state + # Add Entity with a different direction should not set assumed state hass.states.async_set( PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, @@ -325,11 +317,10 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert ATTR_PERCENTAGE in state.attributes assert state.attributes[ATTR_PERCENTAGE] == 50 assert state.attributes[ATTR_OSCILLATING] is True - assert ATTR_ASSUMED_STATE in state.attributes # Now that everything is the same, no longer assumed state @@ -576,7 +567,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["fan.bedroom_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index e4d737b04e2..3ea75fbce06 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -616,7 +616,6 @@ async def test_service_group_services_add_remove_entities(hass: HomeAssistant) - assert "person.one" not in list(group_state.attributes["entity_id"]) -# pylint: disable=invalid-name async def test_service_group_set_group_remove_group(hass: HomeAssistant) -> None: """Check if service are available.""" with assert_setup_component(0, "group"): diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 539a8c61414..062cf161bb9 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -1,7 +1,7 @@ """The tests for the Group Light platform.""" +import asyncio from unittest.mock import MagicMock, patch -import async_timeout import pytest from homeassistant import config as hass_config @@ -1643,7 +1643,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["light.bedroom_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TOGGLE, diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index 2a1a2a05e4e..e1f269a947d 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -1,7 +1,7 @@ """The tests for the Media group platform.""" +import asyncio from unittest.mock import Mock, patch -import async_timeout import pytest from homeassistant.components.group import DOMAIN @@ -583,7 +583,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["media_player.group_1"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( MEDIA_DOMAIN, SERVICE_TURN_OFF, diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 29cd389c233..bc9a05f4754 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -1,8 +1,7 @@ """The tests for the Group Switch platform.""" +import asyncio from unittest.mock import patch -import async_timeout - from homeassistant import config as hass_config from homeassistant.components.group import DOMAIN, SERVICE_RELOAD from homeassistant.components.switch import ( @@ -445,7 +444,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ENTITY_ID) == ["switch.some_group"] # Test controlling the nested group - async with async_timeout.timeout(0.5): + async with asyncio.timeout(0.5): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TOGGLE, diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index e980bf214a0..5a89ea8335a 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -413,3 +413,10 @@ async def test_api_reboot_host( assert await handler.async_reboot_host(hass) == {} assert aioclient_mock.call_count == 1 + + +async def test_send_command_invalid_command(hass: HomeAssistant, hassio_stubs) -> None: + """Test send command fails when command is invalid.""" + hassio: HassIO = hass.data["hassio"] + with pytest.raises(HassioAPIError): + await hassio.send_command("/test/../bad") diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index b394d439654..31ee73013da 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import patch import pytest +from voluptuous import Invalid from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import frontend @@ -100,29 +101,29 @@ def mock_all(aioclient_mock, request, os_info): "version_latest": "1.0.0", "version": "1.0.0", "auto_update": True, + "addons": [ + { + "name": "test", + "slug": "test", + "state": "stopped", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "icon": False, + }, + { + "name": "test2", + "slug": "test2", + "state": "stopped", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "icon": False, + }, + ], }, - "addons": [ - { - "name": "test", - "slug": "test", - "installed": True, - "update_available": False, - "version": "1.0.0", - "version_latest": "1.0.0", - "repository": "core", - "url": "https://github.com/home-assistant/addons/test", - }, - { - "name": "test2", - "slug": "test2", - "installed": True, - "update_available": False, - "version": "1.0.0", - "version_latest": "1.0.0", - "repository": "core", - "url": "https://github.com", - }, - ], }, ) aioclient_mock.get( @@ -243,7 +244,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -288,7 +289,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 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"] @@ -307,7 +308,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 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"] @@ -324,7 +325,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 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"] @@ -404,7 +405,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 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 @@ -421,7 +422,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -441,7 +442,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -486,13 +487,17 @@ async def test_service_register(hassio_env, hass: HomeAssistant) -> None: @pytest.mark.freeze_time("2021-11-13 11:48:00") async def test_service_calls( - hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, ) -> None: """Call service and check the API calls behind that.""" - assert await async_setup_component(hass, "hassio", {}) + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.is_connected", + return_value=None, + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() aioclient_mock.post("http://127.0.0.1/addons/test/start", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) @@ -519,14 +524,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 26 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 == 12 + assert aioclient_mock.call_count == 28 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -541,7 +546,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 14 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "homeassistant": True, @@ -566,7 +571,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -584,7 +589,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 17 + assert aioclient_mock.call_count == 33 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -599,13 +604,35 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, } +async def test_invalid_service_calls( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call service with invalid input and check that it raises.""" + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.is_connected", + return_value=None, + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + with pytest.raises(Invalid): + await hass.services.async_call( + "hassio", "addon_start", {"addon": "does_not_exist"} + ) + with pytest.raises(Invalid): + await hass.services.async_call( + "hassio", "addon_stdin", {"addon": "does_not_exist", "input": "test"} + ) + + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: @@ -864,6 +891,7 @@ async def test_coordinator_updates( @pytest.mark.parametrize( ("extra_os_info", "integration"), [ + ({"board": "green"}, "homeassistant_green"), ({"board": "odroid-c2"}, "hardkernel"), ({"board": "odroid-c4"}, "hardkernel"), ({"board": "odroid-n2"}, "hardkernel"), @@ -889,7 +917,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 237c20a5272..21bf7e5b47a 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -84,6 +84,7 @@ async def test_supervisor_issue_repair_flow( "errors": None, "description_placeholders": {"reference": "/dev/sda1"}, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -292,6 +293,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "errors": None, "description_placeholders": None, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -371,6 +373,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "errors": None, "description_placeholders": None, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -580,6 +583,7 @@ async def test_supervisor_issue_docker_config_repair_flow( "errors": None, "description_placeholders": {"components": "Home Assistant\n- test"}, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 91439a11d95..28228788cf5 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from here_routing import ( HERERoutingError, HERERoutingTooManyRequestsError, @@ -64,7 +65,6 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from .conftest import RESPONSE, TRANSIT_RESPONSE from .const import ( @@ -662,7 +662,9 @@ async def test_transit_errors( async def test_routing_rate_limit( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test that rate limiting is applied when encountering HTTP 429.""" with patch( @@ -689,9 +691,8 @@ async def test_routing_rate_limit( "Rate limit for this service has been reached" ), ): - async_fire_time_changed( - hass, utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "unavailable" @@ -701,18 +702,17 @@ async def test_routing_rate_limit( "here_routing.HERERoutingApi.route", return_value=RESPONSE, ): - async_fire_time_changed( - hass, - utcnow() - + timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1), - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "13.682" assert "Resetting update interval to" in caplog.text async def test_transit_rate_limit( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test that rate limiting is applied when encountering HTTP 429.""" with patch( @@ -747,9 +747,8 @@ async def test_transit_rate_limit( "Rate limit for this service has been reached" ), ): - async_fire_time_changed( - hass, utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "unavailable" @@ -759,11 +758,8 @@ async def test_transit_rate_limit( "here_transit.HERETransitApi.route", return_value=TRANSIT_RESPONSE, ): - async_fire_time_changed( - hass, - utcnow() - + timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1), - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.test_distance").state == "1.883" assert "Resetting update interval to" in caplog.text diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 04384834282..356fbb86b01 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -1,5 +1,4 @@ """The tests the History component.""" -# pylint: disable=invalid-name from datetime import timedelta from http import HTTPStatus import json diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 32358e95e41..caf151cafe7 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -1,7 +1,6 @@ """The tests the History component.""" from __future__ import annotations -# pylint: disable=invalid-name from datetime import timedelta from http import HTTPStatus import json diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index 4f00e50def1..9ba47303e53 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -1,10 +1,8 @@ """The tests the History component websocket_api.""" -# pylint: disable=protected-access,invalid-name import asyncio from datetime import timedelta from unittest.mock import patch -import async_timeout from freezegun import freeze_time import pytest @@ -560,12 +558,12 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": True, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 1 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response == { "event": { @@ -591,13 +589,13 @@ async def test_history_stream_significant_domain_historical_only( "minimal_response": True, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 2 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] assert len(sensor_test_history) == 5 @@ -626,13 +624,13 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": False, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 3 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] @@ -663,13 +661,13 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": False, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 4 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] @@ -708,13 +706,13 @@ async def test_history_stream_significant_domain_historical_only( "no_attributes": False, } ) - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() assert response["success"] assert response["id"] == 5 assert response["type"] == "result" - async with async_timeout.timeout(3): + async with asyncio.timeout(3): response = await client.receive_json() sensor_test_history = response["event"]["states"]["climate.test"] diff --git a/tests/components/history/test_websocket_api_schema_32.py b/tests/components/history/test_websocket_api_schema_32.py index aebf5aa7ac2..6ef6f7225c1 100644 --- a/tests/components/history/test_websocket_api_schema_32.py +++ b/tests/components/history/test_websocket_api_schema_32.py @@ -1,5 +1,4 @@ """The tests the History component websocket_api.""" -# pylint: disable=protected-access,invalid-name import pytest diff --git a/tests/components/home_plus_control/test_switch.py b/tests/components/home_plus_control/test_switch.py index ead1f83cb94..d41977d57a9 100644 --- a/tests/components/home_plus_control/test_switch.py +++ b/tests/components/home_plus_control/test_switch.py @@ -143,7 +143,7 @@ async def test_plant_topology_reduction_change( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -205,7 +205,7 @@ async def test_plant_topology_increase_change( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -267,7 +267,7 @@ async def test_module_status_unavailable( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -338,7 +338,7 @@ async def test_module_status_available( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -442,7 +442,7 @@ async def test_update_with_api_error( side_effect=HomePlusControlApiError, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 diff --git a/tests/components/homeassistant/triggers/test_event.py b/tests/components/homeassistant/triggers/test_event.py index 6fc7e5055ed..d996cd74da7 100644 --- a/tests/components/homeassistant/triggers/test_event.py +++ b/tests/components/homeassistant/triggers/test_event.py @@ -288,7 +288,11 @@ async def test_if_fires_on_event_with_empty_data_and_context_config( async def test_if_fires_on_event_with_nested_data(hass: HomeAssistant, calls) -> None: - """Test the firing of events with nested data.""" + """Test the firing of events with nested data. + + This test exercises the slow path of using vol.Schema to validate + matching event data. + """ assert await async_setup_component( hass, automation.DOMAIN, @@ -311,6 +315,87 @@ async def test_if_fires_on_event_with_nested_data(hass: HomeAssistant, calls) -> assert len(calls) == 1 +async def test_if_fires_on_event_with_empty_data(hass: HomeAssistant, calls) -> None: + """Test the firing of events with empty data. + + This test exercises the fast path to validate matching event data. + """ + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "event", + "event_type": "test_event", + "event_data": {}, + }, + "action": {"service": "test.automation"}, + } + }, + ) + hass.bus.async_fire("test_event", {"any_attr": {}}) + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_if_fires_on_sample_zha_event(hass: HomeAssistant, calls) -> None: + """Test the firing of events with a sample zha event. + + This test exercises the fast path to validate matching event data. + """ + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "event", + "event_type": "zha_event", + "event_data": { + "device_ieee": "00:15:8d:00:02:93:04:11", + "command": "attribute_updated", + "args": { + "attribute_id": 0, + "attribute_name": "on_off", + "value": True, + }, + }, + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.bus.async_fire( + "zha_event", + { + "device_ieee": "00:15:8d:00:02:93:04:11", + "unique_id": "00:15:8d:00:02:93:04:11:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "command": "attribute_updated", + "args": {"attribute_id": 0, "attribute_name": "on_off", "value": True}, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + hass.bus.async_fire( + "zha_event", + { + "device_ieee": "00:15:8d:00:02:93:04:11", + "unique_id": "00:15:8d:00:02:93:04:11:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "command": "attribute_updated", + "args": {"attribute_id": 0, "attribute_name": "on_off", "value": False}, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_if_not_fires_if_event_data_not_matches( hass: HomeAssistant, calls ) -> None: @@ -362,7 +447,11 @@ async def test_if_not_fires_if_event_context_not_matches( async def test_if_fires_on_multiple_user_ids( hass: HomeAssistant, calls, context_with_user ) -> None: - """Test the firing of event when the trigger has multiple user ids.""" + """Test the firing of event when the trigger has multiple user ids. + + This test exercises the slow path of using vol.Schema to validate + matching event context. + """ assert await async_setup_component( hass, automation.DOMAIN, diff --git a/tests/components/homeassistant/triggers/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py index 2098d266f0d..b5bd748a5dc 100644 --- a/tests/components/homeassistant/triggers/test_numeric_state.py +++ b/tests/components/homeassistant/triggers/test_numeric_state.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -1147,7 +1148,7 @@ async def test_if_fails_setup_for_without_above_below( ), ) async def test_if_not_fires_on_entity_change_with_for( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below ) -> None: """Test for not firing on entity change with for.""" assert await async_setup_component( @@ -1171,7 +1172,8 @@ async def test_if_not_fires_on_entity_change_with_for( await hass.async_block_till_done() hass.states.async_set("test.entity", 15) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(calls) == 0 @@ -1244,7 +1246,7 @@ async def test_if_not_fires_on_entities_change_with_for_after_stop( ), ) async def test_if_fires_on_entity_change_with_for_attribute_change( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below ) -> None: """Test for firing on entity change with for and attribute change.""" hass.states.async_set("test.entity", 0) @@ -1267,20 +1269,17 @@ async def test_if_fires_on_entity_change_with_for_attribute_change( }, ) - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=4) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity", 9, attributes={"mock_attr": "attr_change"}) - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=4) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 + hass.states.async_set("test.entity", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity", 9, attributes={"mock_attr": "attr_change"}) + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 @pytest.mark.parametrize( @@ -1374,7 +1373,7 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls, above) -> ), ) async def test_if_fires_on_entities_change_no_overlap( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below ) -> None: """Test for firing on entities change with no overlap.""" hass.states.async_set("test.entity_1", 0) @@ -1402,24 +1401,21 @@ async def test_if_fires_on_entities_change_no_overlap( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=10) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1" + hass.states.async_set("test.entity_1", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1" - hass.states.async_set("test.entity_2", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=10) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "test.entity_2" + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2" @pytest.mark.parametrize( @@ -1432,7 +1428,7 @@ async def test_if_fires_on_entities_change_no_overlap( ), ) async def test_if_fires_on_entities_change_overlap( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below ) -> None: """Test for firing on entities change with overlap.""" hass.states.async_set("test.entity_1", 0) @@ -1460,35 +1456,32 @@ async def test_if_fires_on_entities_change_overlap( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 15) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 9) - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1" + hass.states.async_set("test.entity_1", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 15) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1" - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "test.entity_2" + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2" @pytest.mark.parametrize( @@ -1699,7 +1692,7 @@ async def test_invalid_for_template(hass: HomeAssistant, calls, above, below) -> ), ) async def test_if_fires_on_entities_change_overlap_for_template( - hass: HomeAssistant, calls, above, below + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below ) -> None: """Test for firing on entities change with overlap and for template.""" hass.states.async_set("test.entity_1", 0) @@ -1730,39 +1723,36 @@ async def test_if_fires_on_entities_change_overlap_for_template( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 15) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 9) - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1 - 0:00:05" + hass.states.async_set("test.entity_1", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 15) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1 - 0:00:05" - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - mock_utcnow.return_value += timedelta(seconds=5) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "test.entity_2 - 0:00:10" + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2 - 0:00:10" async def test_below_above(hass: HomeAssistant) -> None: @@ -1861,7 +1851,9 @@ async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop( ("above", "below"), ((8, 12),), ) -async def test_variables_priority(hass: HomeAssistant, calls, above, below) -> None: +async def test_variables_priority( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, above, below +) -> None: """Test an externally defined trigger variable is overridden.""" hass.states.async_set("test.entity_1", 0) hass.states.async_set("test.entity_2", 0) @@ -1892,29 +1884,26 @@ async def test_variables_priority(hass: HomeAssistant, calls, above, below) -> N ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 9) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 15) - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", 9) - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1 - 0:00:05" + hass.states.async_set("test.entity_1", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 15) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", 9) + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1 - 0:00:05" @pytest.mark.parametrize("multiplier", (1, 5)) diff --git a/tests/components/homeassistant/triggers/test_state.py b/tests/components/homeassistant/triggers/test_state.py index d58e3dd7c6e..9870beedafc 100644 --- a/tests/components/homeassistant/triggers/test_state.py +++ b/tests/components/homeassistant/triggers/test_state.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import homeassistant.components.automation as automation @@ -695,7 +696,7 @@ async def test_if_not_fires_on_entities_change_with_for_after_stop( async def test_if_fires_on_entity_change_with_for_attribute_change( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing on entity change with for and attribute change.""" assert await async_setup_component( @@ -715,26 +716,23 @@ async def test_if_fires_on_entity_change_with_for_attribute_change( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=4) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set( - "test.entity", "world", attributes={"mock_attr": "attr_change"} - ) - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=4) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 + hass.states.async_set("test.entity", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + hass.states.async_set( + "test.entity", "world", attributes={"mock_attr": "attr_change"} + ) + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 async def test_if_fires_on_entity_change_with_for_multiple_force_update( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing on entity change with for and force update.""" assert await async_setup_component( @@ -754,21 +752,18 @@ async def test_if_fires_on_entity_change_with_for_multiple_force_update( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow + hass.states.async_set("test.force_entity", "world", None, True) + await hass.async_block_till_done() + for _ in range(4): + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) hass.states.async_set("test.force_entity", "world", None, True) await hass.async_block_till_done() - for _ in range(4): - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.force_entity", "world", None, True) - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=4) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) == 0 + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 async def test_if_fires_on_entity_change_with_for(hass: HomeAssistant, calls) -> None: @@ -837,7 +832,7 @@ async def test_if_fires_on_entity_change_with_for_without_to( async def test_if_does_not_fires_on_entity_change_with_for_without_to_2( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing on entity change with for.""" assert await async_setup_component( @@ -856,17 +851,12 @@ async def test_if_does_not_fires_on_entity_change_with_for_without_to_2( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - - for i in range(10): - hass.states.async_set("test.entity", str(i)) - await hass.async_block_till_done() - - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() + for i in range(10): + hass.states.async_set("test.entity", str(i)) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert len(calls) == 0 @@ -1110,7 +1100,7 @@ async def test_wait_template_with_trigger(hass: HomeAssistant, calls) -> None: async def test_if_fires_on_entities_change_no_overlap( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing on entities change with no overlap.""" assert await async_setup_component( @@ -1133,27 +1123,26 @@ async def test_if_fires_on_entities_change_no_overlap( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=10) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1" + hass.states.async_set("test.entity_1", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1" - hass.states.async_set("test.entity_2", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=10) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "test.entity_2" + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2" -async def test_if_fires_on_entities_change_overlap(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_entities_change_overlap( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing on entities change with overlap.""" assert await async_setup_component( hass, @@ -1175,35 +1164,32 @@ async def test_if_fires_on_entities_change_overlap(hass: HomeAssistant, calls) - ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "hello") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1" + hass.states.async_set("test.entity_1", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "hello") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1" - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "test.entity_2" + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2" async def test_if_fires_on_change_with_for_template_1( @@ -1402,7 +1388,7 @@ async def test_invalid_for_template_1(hass: HomeAssistant, calls) -> None: async def test_if_fires_on_entities_change_overlap_for_template( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing on entities change with overlap and for template.""" assert await async_setup_component( @@ -1428,39 +1414,36 @@ async def test_if_fires_on_entities_change_overlap_for_template( ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "hello") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1 - 0:00:05" + hass.states.async_set("test.entity_1", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "hello") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1 - 0:00:05" - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - mock_utcnow.return_value += timedelta(seconds=5) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "test.entity_2 - 0:00:10" + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2 - 0:00:10" async def test_attribute_if_fires_on_entity_change_with_both_filters( @@ -1702,7 +1685,9 @@ async def test_attribute_if_fires_on_entity_change_with_both_filters_boolean( assert len(calls) == 1 -async def test_variables_priority(hass: HomeAssistant, calls) -> None: +async def test_variables_priority( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test an externally defined trigger variable is overridden.""" assert await async_setup_component( hass, @@ -1728,36 +1713,33 @@ async def test_variables_priority(hass: HomeAssistant, calls) -> None: ) await hass.async_block_till_done() - utcnow = dt_util.utcnow() - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = utcnow - hass.states.async_set("test.entity_1", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "world") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "hello") - await hass.async_block_till_done() - mock_utcnow.return_value += timedelta(seconds=1) - async_fire_time_changed(hass, mock_utcnow.return_value) - hass.states.async_set("test.entity_2", "world") - await hass.async_block_till_done() - assert len(calls) == 0 - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == "test.entity_1 - 0:00:05" + hass.states.async_set("test.entity_1", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "hello") + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + hass.states.async_set("test.entity_2", "world") + await hass.async_block_till_done() + assert len(calls) == 0 + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "test.entity_1 - 0:00:05" - mock_utcnow.return_value += timedelta(seconds=3) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 1 - mock_utcnow.return_value += timedelta(seconds=5) - async_fire_time_changed(hass, mock_utcnow.return_value) - await hass.async_block_till_done() - assert len(calls) == 2 - assert calls[1].data["some"] == "test.entity_2 - 0:00:10" + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 1 + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "test.entity_2 - 0:00:10" diff --git a/tests/components/homeassistant_green/__init__.py b/tests/components/homeassistant_green/__init__.py new file mode 100644 index 00000000000..a84e076d9c9 --- /dev/null +++ b/tests/components/homeassistant_green/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Green integration.""" diff --git a/tests/components/homeassistant_green/test_config_flow.py b/tests/components/homeassistant_green/test_config_flow.py new file mode 100644 index 00000000000..2eb7389af55 --- /dev/null +++ b/tests/components/homeassistant_green/test_config_flow.py @@ -0,0 +1,58 @@ +"""Test the Home Assistant Green config flow.""" +from unittest.mock import patch + +from homeassistant.components.homeassistant_green.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test the config flow.""" + mock_integration(hass, MockModule("hassio")) + + with patch( + "homeassistant.components.homeassistant_green.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Home Assistant Green" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == {} + assert config_entry.title == "Home Assistant Green" + + +async def test_config_flow_single_entry(hass: HomeAssistant) -> None: + """Test only a single entry is allowed.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + mock_setup_entry.assert_not_called() diff --git a/tests/components/homeassistant_green/test_hardware.py b/tests/components/homeassistant_green/test_hardware.py new file mode 100644 index 00000000000..8aacf09978d --- /dev/null +++ b/tests/components/homeassistant_green/test_hardware.py @@ -0,0 +1,96 @@ +"""Test the Home Assistant Green hardware platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_green.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.typing import WebSocketGenerator + + +async def test_hardware_info( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we can get the board info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_green.get_os_info", + return_value={"board": "green"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_green.hardware.get_os_info", + return_value={"board": "green"}, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == { + "hardware": [ + { + "board": { + "hassio_board_id": "green", + "manufacturer": "homeassistant", + "model": "green", + "revision": None, + }, + "config_entries": [config_entry.entry_id], + "dongle": None, + "name": "Home Assistant Green", + "url": None, + } + ] + } + + +@pytest.mark.parametrize("os_info", [None, {"board": None}, {"board": "other"}]) +async def test_hardware_info_fail( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, os_info +) -> None: + """Test async_info raises if os_info is not as expected.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_green.get_os_info", + return_value={"board": "green"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_green.hardware.get_os_info", + return_value=os_info, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == {"hardware": []} diff --git a/tests/components/homeassistant_green/test_init.py b/tests/components/homeassistant_green/test_init.py new file mode 100644 index 00000000000..f48aea3fdfb --- /dev/null +++ b/tests/components/homeassistant_green/test_init.py @@ -0,0 +1,75 @@ +"""Test the Home Assistant Green integration.""" +from unittest.mock import patch + +from homeassistant.components.homeassistant_green.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_setup_entry(hass: HomeAssistant) -> None: + """Test setup of a config entry.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_green.get_os_info", + return_value={"board": "green"}, + ) as mock_get_os_info: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + # Test unloading the config entry + assert await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: + """Test setup of a config entry with wrong board type.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_green.get_os_info", + return_value={"board": "generic-x86-64"}, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + +async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: + """Test setup of a config entry when hassio has not fetched os_info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_green.get_os_info", + return_value=None, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index 60c766c7204..60083c2de94 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -147,3 +147,21 @@ def start_addon_fixture(): "homeassistant.components.hassio.addon_manager.async_start_addon" ) as start_addon: yield start_addon + + +@pytest.fixture(name="stop_addon") +def stop_addon_fixture(): + """Mock stop add-on.""" + with patch( + "homeassistant.components.hassio.addon_manager.async_stop_addon" + ) as stop_addon: + yield stop_addon + + +@pytest.fixture(name="uninstall_addon") +def uninstall_addon_fixture(): + """Mock uninstall add-on.""" + with patch( + "homeassistant.components.hassio.addon_manager.async_uninstall_addon" + ) as uninstall_addon: + yield uninstall_addon diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index a956214c098..17cd288050c 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Generator from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest +from homeassistant.components.hassio import AddonError, AddonInfo, AddonState, HassIO from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN @@ -14,15 +15,15 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import ATTR_COMPONENT from tests.common import ( MockConfigEntry, - MockModule, MockPlatform, flush_store, + mock_component, mock_config_flow, - mock_integration, mock_platform, ) @@ -101,6 +102,22 @@ def config_flow_handler( yield +@pytest.fixture +def options_flow_poll_addon_state() -> Generator[None, None, None]: + """Fixture for patching options flow addon state polling.""" + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.WaitingAddonManager.async_wait_until_addon_state" + ): + yield + + +@pytest.fixture(autouse=True) +def hassio_integration(hass: HomeAssistant) -> Generator[None, None, None]: + """Fixture to mock the `hassio` integration.""" + mock_component(hass, "hassio") + hass.data["hassio"] = Mock(spec_set=HassIO) + + class MockMultiprotocolPlatform(MockPlatform): """A mock multiprotocol platform.""" @@ -149,6 +166,48 @@ def get_suggested(schema, key): raise Exception +@patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.ADDON_STATE_POLL_INTERVAL", + 0, +) +async def test_uninstall_addon_waiting( + hass: HomeAssistant, + addon_store_info, + addon_info, + install_addon, + uninstall_addon, +): + """Test the synchronous addon uninstall helper.""" + + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) + multipan_manager.async_get_addon_info = AsyncMock() + multipan_manager.async_uninstall_addon = AsyncMock( + wraps=multipan_manager.async_uninstall_addon + ) + + # First try uninstalling the addon when it is already uninstalled + multipan_manager.async_get_addon_info.side_effect = [ + Mock(state=AddonState.NOT_INSTALLED) + ] + await multipan_manager.async_uninstall_addon_waiting() + multipan_manager.async_uninstall_addon.assert_not_called() + + # Next, try uninstalling the addon but in a complex case where the API fails first + multipan_manager.async_get_addon_info.side_effect = [ + # First the API fails + AddonError(), + AddonError(), + # Then the addon is still running + Mock(state=AddonState.RUNNING), + # And finally it is uninstalled + Mock(state=AddonState.NOT_INSTALLED), + ] + await multipan_manager.async_uninstall_addon_waiting() + multipan_manager.async_uninstall_addon.assert_called_once() + + async def test_option_flow_install_multi_pan_addon( hass: HomeAssistant, addon_store_info, @@ -156,9 +215,9 @@ async def test_option_flow_install_multi_pan_addon( install_addon, set_addon_options, start_addon, + options_flow_poll_addon_state, ) -> None: """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) # Setup the config entry config_entry = MockConfigEntry( @@ -169,13 +228,9 @@ async def test_option_flow_install_multi_pan_addon( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -224,9 +279,9 @@ async def test_option_flow_install_multi_pan_addon_zha( install_addon, set_addon_options, start_addon, + options_flow_poll_addon_state, ) -> None: """Test installing the multi pan addon when a zha config entry exists.""" - mock_integration(hass, MockModule("hassio")) # Setup the config entry config_entry = MockConfigEntry( @@ -245,13 +300,9 @@ async def test_option_flow_install_multi_pan_addon_zha( ) zha_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -268,7 +319,9 @@ async def test_option_flow_install_multi_pan_addon_zha( assert result["step_id"] == "configure_addon" install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) assert multipan_manager._channel is None with patch( "homeassistant.components.zha.silabs_multiprotocol.async_get_channel", @@ -318,9 +371,9 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( install_addon, set_addon_options, start_addon, + options_flow_poll_addon_state, ) -> None: """Test installing the multi pan addon when a zha config entry exists.""" - mock_integration(hass, MockModule("hassio")) # Setup the config entry config_entry = MockConfigEntry( @@ -346,13 +399,9 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( ) zha_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -408,31 +457,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( async def test_option_flow_non_hassio( hass: HomeAssistant, ) -> None: - """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) - - # Setup the config entry - config_entry = MockConfigEntry( - data={}, - domain=TEST_DOMAIN, - options={}, - title="Test HW", - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "not_hassio" - - -async def test_option_flow_addon_installed_other_device( - hass: HomeAssistant, - addon_store_info, - addon_installed, -) -> None: - """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) - + """Test installing the multi pan addon on a Core installation, without hassio.""" # Setup the config entry config_entry = MockConfigEntry( data={}, @@ -444,11 +469,33 @@ async def test_option_flow_addon_installed_other_device( with patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), + return_value=False, ): result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_installed_other_device" + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + +async def test_option_flow_addon_installed_other_device( + hass: HomeAssistant, + addon_store_info, + addon_installed, +) -> None: + """Test installing the multi pan addon.""" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_installed_other_device" result = await hass.config_entries.options.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -467,10 +514,12 @@ async def test_option_flow_addon_installed_same_device_reconfigure_unexpected_us suggested_channel: int, ) -> None: """Test reconfiguring the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" - multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) multipan_manager._channel = configured_channel # Setup the config entry @@ -482,13 +531,9 @@ async def test_option_flow_addon_installed_same_device_reconfigure_unexpected_us ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.MENU - assert result["step_id"] == "addon_menu" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -528,10 +573,12 @@ async def test_option_flow_addon_installed_same_device_reconfigure_expected_user suggested_channel: int, ) -> None: """Test reconfiguring the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" - multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) multipan_manager._channel = configured_channel # Setup the config entry @@ -557,13 +604,9 @@ async def test_option_flow_addon_installed_same_device_reconfigure_expected_user hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: domain}) await hass.async_block_till_done() - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.MENU - assert result["step_id"] == "addon_menu" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -593,9 +636,15 @@ async def test_option_flow_addon_installed_same_device_uninstall( addon_info, addon_store_info, addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, + options_flow_poll_addon_state, ) -> None: - """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + """Test uninstalling the multi pan addon.""" + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" # Setup the config entry @@ -607,32 +656,97 @@ async def test_option_flow_addon_installed_same_device_uninstall( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.MENU - assert result["step_id"] == "addon_menu" + zha_config_entry = MockConfigEntry( + data={ + "device": {"path": "socket://core-silabs-multiprotocol:9999"}, + "radio_type": "ezsp", + }, + domain=ZHA_DOMAIN, + options={}, + title="Test Multi-PAN", + ) + zha_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" result = await hass.config_entries.options.async_configure( result["flow_id"], {"next_step_id": "uninstall_addon"}, ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "show_revert_guide" + assert result["step_id"] == "uninstall_addon" - result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + # Make sure the flasher addon is installed + addon_store_info.return_value = { + "installed": None, + "available": True, + "state": "not_installed", + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} + ) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + 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" + + 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"]) + 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 + assert result["step_id"] == "start_flasher_addon" + 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"]) + 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 + # Check the ZHA config entry data is updated + assert zha_config_entry.data == { + "device": { + "path": "/dev/ttyTEST123", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "ezsp", + } + assert zha_config_entry.title == "Test" -async def test_option_flow_do_not_install_multi_pan_addon( + +async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pan( hass: HomeAssistant, addon_info, addon_store_info, + addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, ) -> None: - """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + """Test uninstalling the multi pan addon.""" + + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -643,13 +757,439 @@ async def test_option_flow_do_not_install_multi_pan_addon( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "uninstall_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: False} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_option_flow_flasher_already_running_failure( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, + options_flow_poll_addon_state, +) -> None: + """Test uninstalling the multi pan addon but with the flasher addon running.""" + + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "uninstall_addon" + + # The flasher addon is already installed and running, this is bad + addon_store_info.return_value["installed"] = True + addon_info.return_value["state"] = "started" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "addon_already_running" + + +async def test_option_flow_addon_installed_same_device_flasher_already_installed( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, + options_flow_poll_addon_state, +) -> None: + """Test uninstalling the multi pan addon.""" + + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "uninstall_addon" + + addon_store_info.return_value = { + "installed": True, + "available": True, + "state": "not_running", + } + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} + ) + 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"]) + 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 + assert result["step_id"] == "start_flasher_addon" + assert result["progress_action"] == "start_flasher_addon" + assert result["description_placeholders"] == {"addon_name": "Silicon Labs Flasher"} + + addon_store_info.return_value = { + "installed": True, + "available": True, + "state": "not_running", + } + result = await hass.config_entries.options.async_configure(result["flow_id"]) + 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 + + +async def test_option_flow_flasher_install_failure( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, + options_flow_poll_addon_state, +) -> None: + """Test uninstalling the multi pan addon, case where flasher addon fails.""" + + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + zha_config_entry = MockConfigEntry( + data={ + "device": {"path": "socket://core-silabs-multiprotocol:9999"}, + "radio_type": "ezsp", + }, + domain=ZHA_DOMAIN, + options={}, + title="Test Multi-PAN", + ) + zha_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "uninstall_addon" + + addon_store_info.return_value = { + "installed": None, + "available": True, + "state": "not_installed", + } + install_addon.side_effect = [AddonError()] + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} + ) + + assert result["type"] == FlowResultType.SHOW_PROGRESS + 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" + install_addon.assert_called_once_with(hass, "core_silabs_flasher") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "addon_install_failed" + + +async def test_option_flow_flasher_addon_flash_failure( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, + options_flow_poll_addon_state, +) -> None: + """Test where flasher addon fails to flash Zigbee firmware.""" + + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "uninstall_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "uninstall_multiprotocol_addon" + assert result["progress_action"] == "uninstall_multiprotocol_addon" + + start_addon.side_effect = HassioAPIError("Boom") + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + 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 + assert result["step_id"] == "start_flasher_addon" + 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" + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "addon_start_failed" + assert result["description_placeholders"]["addon_name"] == "Silicon Labs Flasher" + + +@patch( + "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_initiate_migration", + side_effect=Exception("Boom!"), +) +async def test_option_flow_uninstall_migration_initiate_failure( + mock_initiate_migration, + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, + options_flow_poll_addon_state, +) -> None: + """Test uninstalling the multi pan addon, case where ZHA migration init fails.""" + + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + zha_config_entry = MockConfigEntry( + data={ + "device": {"path": "socket://core-silabs-multiprotocol:9999"}, + "radio_type": "ezsp", + }, + domain=ZHA_DOMAIN, + options={}, + title="Test Multi-PAN", + ) + zha_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "uninstall_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "zha_migration_failed" + mock_initiate_migration.assert_called_once() + + +@patch( + "homeassistant.components.zha.radio_manager.ZhaMultiPANMigrationHelper.async_finish_migration", + side_effect=Exception("Boom!"), +) +async def test_option_flow_uninstall_migration_finish_failure( + mock_finish_migration, + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + install_addon, + start_addon, + stop_addon, + uninstall_addon, + set_addon_options, + options_flow_poll_addon_state, +) -> None: + """Test uninstalling the multi pan addon, case where ZHA migration init fails.""" + + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + zha_config_entry = MockConfigEntry( + data={ + "device": {"path": "socket://core-silabs-multiprotocol:9999"}, + "radio_type": "ezsp", + }, + domain=ZHA_DOMAIN, + options={}, + title="Test Multi-PAN", + ) + zha_config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "uninstall_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "uninstall_addon" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} + ) + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + 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 + assert result["step_id"] == "start_flasher_addon" + 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" + + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "zha_migration_failed" + + +async def test_option_flow_do_not_install_multi_pan_addon( + hass: HomeAssistant, + addon_info, + addon_store_info, +) -> None: + """Test installing the multi pan addon.""" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -669,7 +1209,7 @@ async def test_option_flow_install_multi_pan_addon_install_fails( start_addon, ) -> None: """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + install_addon.side_effect = HassioAPIError("Boom") # Setup the config entry @@ -681,13 +1221,9 @@ async def test_option_flow_install_multi_pan_addon_install_fails( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -718,7 +1254,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( start_addon, ) -> None: """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + start_addon.side_effect = HassioAPIError("Boom") # Setup the config entry @@ -730,13 +1266,9 @@ async def test_option_flow_install_multi_pan_addon_start_fails( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -788,7 +1320,7 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( start_addon, ) -> None: """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + set_addon_options.side_effect = HassioAPIError("Boom") # Setup the config entry @@ -800,13 +1332,9 @@ async def test_option_flow_install_multi_pan_addon_set_options_fails( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -834,7 +1362,7 @@ async def test_option_flow_addon_info_fails( addon_info, ) -> None: """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) + addon_store_info.side_effect = HassioAPIError("Boom") # Setup the config entry @@ -846,13 +1374,9 @@ async def test_option_flow_addon_info_fails( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_info_failed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "addon_info_failed" @patch( @@ -869,7 +1393,6 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( start_addon, ) -> None: """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) # Setup the config entry config_entry = MockConfigEntry( @@ -888,13 +1411,9 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_1( ) zha_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -929,9 +1448,9 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( install_addon, set_addon_options, start_addon, + options_flow_poll_addon_state, ) -> None: """Test installing the multi pan addon.""" - mock_integration(hass, MockModule("hassio")) # Setup the config entry config_entry = MockConfigEntry( @@ -950,13 +1469,9 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( ) zha_config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", - side_effect=Mock(return_value=True), - ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "addon_not_installed" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "addon_not_installed" result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -1032,7 +1547,9 @@ async def test_import_channel( new_multipan_channel: int | None, ) -> None: """Test channel is initialized from first platform.""" - multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) multipan_manager._channel = initial_multipan_channel mock_multiprotocol_platform = MockMultiprotocolPlatform() @@ -1066,7 +1583,9 @@ async def test_change_channel( expected_calls: list[int], ) -> None: """Test channel is initialized from first platform.""" - multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) mock_multiprotocol_platform.using_multipan = platform_using_multipan await multipan_manager.async_change_channel(15, 10) @@ -1075,7 +1594,9 @@ async def test_change_channel( async def test_load_preferences(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" - multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) assert multipan_manager._channel != 11 multipan_manager.async_set_channel(11) @@ -1106,7 +1627,9 @@ async def test_active_plaforms( active_platforms: list[str], ) -> None: """Test async_active_platforms.""" - multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( + hass + ) for domain, platform_using_multipan in multipan_platforms.items(): mock_multiprotocol_platform = MockMultiprotocolPlatform() @@ -1121,3 +1644,151 @@ async def test_active_plaforms( await hass.async_block_till_done() assert await multipan_manager.async_active_platforms() == active_platforms + + +async def test_check_multi_pan_addon_no_hassio(hass: HomeAssistant) -> None: + """Test `check_multi_pan_addon` without hassio.""" + + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", + return_value=False, + ), patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", + autospec=True, + ) as mock_get_addon_manager: + await silabs_multiprotocol_addon.check_multi_pan_addon(hass) + mock_get_addon_manager.assert_not_called() + + +async def test_check_multi_pan_addon_info_error( + hass: HomeAssistant, addon_store_info +) -> None: + """Test `check_multi_pan_addon` where the addon info cannot be read.""" + + addon_store_info.side_effect = HassioAPIError("Boom") + + with pytest.raises(HomeAssistantError): + await silabs_multiprotocol_addon.check_multi_pan_addon(hass) + + +async def test_check_multi_pan_addon_bad_state(hass: HomeAssistant) -> None: + """Test `check_multi_pan_addon` where the addon is in an unexpected state.""" + + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", + return_value=Mock( + spec_set=silabs_multiprotocol_addon.MultiprotocolAddonManager + ), + ) as mock_get_addon_manager: + manager = mock_get_addon_manager.return_value + manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_silabs_multiprotocol", + options={}, + state=AddonState.UPDATING, + update_available=False, + version="1.0.0", + ) + + with pytest.raises(HomeAssistantError): + await silabs_multiprotocol_addon.check_multi_pan_addon(hass) + + manager.async_start_addon.assert_not_called() + + +async def test_check_multi_pan_addon_auto_start( + hass: HomeAssistant, addon_info, addon_store_info, start_addon +) -> None: + """Test `check_multi_pan_addon` auto starting the addon.""" + + addon_info.return_value["state"] = "not_running" + addon_store_info.return_value = { + "installed": True, + "available": True, + "state": "not_running", + } + + # An error is raised even if we auto-start + with pytest.raises(HomeAssistantError): + await silabs_multiprotocol_addon.check_multi_pan_addon(hass) + + start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + + +async def test_check_multi_pan_addon( + hass: HomeAssistant, addon_info, addon_store_info, start_addon +) -> None: + """Test `check_multi_pan_addon`.""" + + addon_info.return_value["state"] = "started" + addon_store_info.return_value = { + "installed": True, + "available": True, + "state": "running", + } + + await silabs_multiprotocol_addon.check_multi_pan_addon(hass) + start_addon.assert_not_called() + + +async def test_multi_pan_addon_using_device_no_hassio(hass: HomeAssistant) -> None: + """Test `multi_pan_addon_using_device` without hassio.""" + + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", + return_value=False, + ): + assert ( + await silabs_multiprotocol_addon.multi_pan_addon_using_device( + hass, "/dev/ttyAMA1" + ) + is False + ) + + +async def test_multi_pan_addon_using_device_not_running( + hass: HomeAssistant, addon_info, addon_store_info +) -> None: + """Test `multi_pan_addon_using_device` when the addon isn't running.""" + + addon_info.return_value["state"] = "not_running" + addon_store_info.return_value = { + "installed": True, + "available": True, + "state": "not_running", + } + + await silabs_multiprotocol_addon.multi_pan_addon_using_device( + hass, "/dev/ttyAMA1" + ) is False + + +@pytest.mark.parametrize( + ("options_device", "expected_result"), + [("/dev/ttyAMA2", False), ("/dev/ttyAMA1", True)], +) +async def test_multi_pan_addon_using_device( + hass: HomeAssistant, + addon_info, + addon_store_info, + options_device: str, + expected_result: bool, +) -> None: + """Test `multi_pan_addon_using_device` when the addon isn't running.""" + + addon_info.return_value["state"] = "started" + addon_info.return_value["options"] = { + "autoflash_firmware": True, + "device": options_device, + "baudrate": "115200", + "flow_control": True, + } + addon_store_info.return_value = { + "installed": True, + "available": True, + "state": "running", + } + + await silabs_multiprotocol_addon.multi_pan_addon_using_device( + hass, "/dev/ttyAMA1" + ) is expected_result diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 3677b4ea8f1..85017866db9 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -9,7 +9,7 @@ import pytest def mock_usb_serial_by_id_fixture() -> Generator[MagicMock, None, None]: """Mock usb serial by id.""" with patch( - "homeassistant.components.zwave_js.config_flow.usb.get_serial_by_id" + "homeassistant.components.zha.config_flow.usb.get_serial_by_id" ) as mock_usb_serial_by_id: mock_usb_serial_by_id.side_effect = lambda x: x yield mock_usb_serial_by_id @@ -149,3 +149,21 @@ def start_addon_fixture(): "homeassistant.components.hassio.addon_manager.async_start_addon" ) as start_addon: yield start_addon + + +@pytest.fixture(name="stop_addon") +def stop_addon_fixture(): + """Mock stop add-on.""" + with patch( + "homeassistant.components.hassio.addon_manager.async_stop_addon" + ) as stop_addon: + yield stop_addon + + +@pytest.fixture(name="uninstall_addon") +def uninstall_addon_fixture(): + """Mock uninstall add-on.""" + with patch( + "homeassistant.components.hassio.addon_manager.async_uninstall_addon" + ) as uninstall_addon: + yield uninstall_addon diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index c74adbf32ea..9e1977192e9 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -1,7 +1,10 @@ """Test the Home Assistant SkyConnect config flow.""" +from collections.abc import Generator import copy from unittest.mock import Mock, patch +import pytest + from homeassistant.components import homeassistant_sky_connect, usb from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.components.zha.core.const import ( @@ -25,6 +28,15 @@ USB_DATA = usb.UsbServiceInfo( ) +@pytest.fixture(autouse=True) +def config_flow_handler(hass: HomeAssistant) -> Generator[None, None, None]: + """Fixture for a test config flow.""" + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.WaitingAddonManager.async_wait_until_addon_state" + ): + yield + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" with patch( diff --git a/tests/components/homeassistant_sky_connect/test_hardware.py b/tests/components/homeassistant_sky_connect/test_hardware.py index 5ddddfc637b..ca9a7887040 100644 --- a/tests/components/homeassistant_sky_connect/test_hardware.py +++ b/tests/components/homeassistant_sky_connect/test_hardware.py @@ -78,7 +78,7 @@ async def test_hardware_info( "description": "bla_description", }, "name": "Home Assistant SkyConnect", - "url": None, + "url": "https://skyconnect.home-assistant.io/documentation/", }, { "board": None, @@ -91,7 +91,7 @@ async def test_hardware_info( "description": "bla_description_2", }, "name": "Home Assistant SkyConnect", - "url": None, + "url": "https://skyconnect.home-assistant.io/documentation/", }, ] } diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 746e119082c..cbf1cfa7d36 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -24,6 +24,13 @@ CONFIG_ENTRY_DATA = { } +@pytest.fixture(autouse=True) +def disable_usb_probing() -> Generator[None, None, None]: + """Disallow touching of system USB devices during unit tests.""" + with patch("homeassistant.components.usb.comports", return_value=[]): + yield + + @pytest.fixture def mock_zha_config_flow_setup() -> Generator[None, None, None]: """Mock the radio connection and probing of the ZHA config flow.""" diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 66401bcd7bc..58d47c41987 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Home Assistant Yellow config flow.""" +from collections.abc import Generator from unittest.mock import Mock, patch import pytest @@ -11,6 +12,15 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(autouse=True) +def config_flow_handler(hass: HomeAssistant) -> Generator[None, None, None]: + """Fixture for a test config flow.""" + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.WaitingAddonManager.async_wait_until_addon_state" + ): + yield + + @pytest.fixture(name="get_yellow_settings") def mock_get_yellow_settings(): """Mock getting yellow settings.""" diff --git a/tests/components/homeassistant_yellow/test_hardware.py b/tests/components/homeassistant_yellow/test_hardware.py index 5fa0e73d82c..5fb662471aa 100644 --- a/tests/components/homeassistant_yellow/test_hardware.py +++ b/tests/components/homeassistant_yellow/test_hardware.py @@ -54,7 +54,7 @@ async def test_hardware_info( "config_entries": [config_entry.entry_id], "dongle": None, "name": "Home Assistant Yellow", - "url": None, + "url": "https://yellow.home-assistant.io/documentation/", } ] } diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 08a7f8a2206..b57dd2da10f 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -3,8 +3,8 @@ from unittest.mock import Mock, patch import pytest -import homeassistant.components.climate as climate -import homeassistant.components.cover as cover +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, @@ -17,9 +17,9 @@ from homeassistant.components.homekit.const import ( TYPE_SWITCH, TYPE_VALVE, ) -import homeassistant.components.media_player.const as media_player_c +from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.sensor import SensorDeviceClass -import homeassistant.components.vacuum as vacuum +from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_CLASS, @@ -90,7 +90,7 @@ def test_customize_options(config, name) -> None: "Thermostat", "climate.test", "auto", - {ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_RANGE}, + {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE}, {}, ), ("HumidifierDehumidifier", "humidifier.test", "auto", {}, {}), @@ -118,7 +118,8 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_DEVICE_CLASS: "garage", - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, }, ), ( @@ -127,26 +128,20 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_DEVICE_CLASS: "window", - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), ( "WindowCovering", "cover.set_position", "open", - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION}, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION}, ), ( "WindowCovering", "cover.tilt", "open", - {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_TILT_POSITION}, - ), - ( - "WindowCoveringBasic", - "cover.open_window", - "open", - {ATTR_SUPPORTED_FEATURES: (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE)}, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION}, ), ( "WindowCoveringBasic", @@ -154,9 +149,19 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_SUPPORTED_FEATURES: ( - cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_SET_TILT_POSITION + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + }, + ), + ( + "WindowCoveringBasic", + "cover.open_window", + "open", + { + ATTR_SUPPORTED_FEATURES: ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_TILT_POSITION ) }, ), @@ -166,7 +171,7 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: "open", { ATTR_DEVICE_CLASS: "door", - ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, }, ), ], @@ -188,8 +193,8 @@ def test_type_covers(type_name, entity_id, state, attrs) -> None: "media_player.test", "on", { - ATTR_SUPPORTED_FEATURES: media_player_c.MediaPlayerEntityFeature.TURN_ON - | media_player_c.MediaPlayerEntityFeature.TURN_OFF + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF }, {CONF_FEATURE_LIST: {FEATURE_ON_OFF: None}}, ), @@ -334,8 +339,8 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None: "vacuum.dock_vacuum", "docked", { - ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_START - | vacuum.SUPPORT_RETURN_HOME + ATTR_SUPPORTED_FEATURES: VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME }, ), ("Vacuum", "vacuum.basic_vacuum", "off", {}), diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 109f4205901..02807ba6557 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1019,6 +1019,7 @@ async def test_homekit_unpair_not_homekit_device( not_homekit_entry = MockConfigEntry( domain="not_homekit", data={CONF_NAME: "mock_name", CONF_PORT: 12345} ) + not_homekit_entry.add_to_hass(hass) entity_id = "light.demo" hass.states.async_set("light.demo", "on") homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 0c27e0a3648..2b532769220 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -3,12 +3,12 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -import json import logging import os from typing import Any, Final from unittest import mock +from aiohomekit.hkjson import loads as hkloads from aiohomekit.model import ( Accessories, AccessoriesState, @@ -185,7 +185,7 @@ async def setup_accessories_from_file(hass, path): accessories_fixture = await hass.async_add_executor_job( load_fixture, os.path.join("homekit_controller", path) ) - accessories_json = json.loads(accessories_fixture) + accessories_json = hkloads(accessories_fixture) accessories = Accessories.from_list(accessories_json) return accessories diff --git a/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json b/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json new file mode 100644 index 00000000000..b3dd6f8a84e --- /dev/null +++ b/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json @@ -0,0 +1,161 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr", "ev"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000053-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Hardware Revision", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "Garzola Marco", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pr"], + "format": "string", + "value": "Daikin-fwec3a-esp32-homekit-bridge", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 7, + "perms": ["pr"], + "format": "string", + "value": "Air Conditioner", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "00000001", + "description": "Serial Number", + "maxLen": 64 + } + ] + }, + { + "iid": 9, + "type": "000000BC-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "000000B0-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 1, + "description": "Active" + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "ev"], + "format": "float", + "value": 27.9, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 99, + "minStep": 0.5 + }, + { + "type": "000000B1-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 3, + "description": "Current Heater Cooler State" + }, + { + "type": "000000B2-0000-1000-8000-0026BB765291", + "iid": 13, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 2, + "description": "Target Heater Cooler State" + }, + { + "type": "0000000D-0000-1000-8000-0026BB765291", + "iid": 14, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 24.5, + "description": "Cooling Threshold Temperature", + "unit": "celsius", + "minValue": 18, + "maxValue": 32, + "minStep": 0.5 + }, + { + "type": "00000012-0000-1000-8000-0026BB765291", + "iid": 15, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 24.5, + "description": "Heating Threshold Temperature", + "unit": "celsius", + "minValue": 13, + "maxValue": 27, + "minStep": 0.5 + }, + { + "type": "00000029-0000-1000-8000-0026BB765291", + "iid": 16, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 100, + "description": "Rotation Speed", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 17, + "perms": ["pr"], + "format": "string", + "value": "SlaveID 1", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py index c90ff20c593..ae44f7f774f 100644 --- a/tests/components/homekit_controller/specific_devices/test_arlo_baby.py +++ b/tests/components/homekit_controller/specific_devices/test_arlo_baby.py @@ -1,6 +1,6 @@ """Make sure that an Arlo Baby can be setup.""" from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from ..common import ( @@ -64,7 +64,7 @@ async def test_arlo_baby_setup(hass: HomeAssistant) -> None: unique_id="00:00:00:00:00:00_1_1000", friendly_name="ArloBabyA0 Temperature", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=TEMP_CELSIUS, + unit_of_measurement=UnitOfTemperature.CELSIUS, state="24.0", ), EntityTestInfo( diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index aa24a3dea68..44157c32203 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -20,7 +20,86 @@ from ..common import ( async def test_connectsense_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "connectsense.json") - await setup_test_accessories(hass, accessories) + config_entry, pairing = await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="InWall Outlet-0394DE", + model="CS-IWO", + manufacturer="ConnectSense", + sw_version="1.0.0", + hw_version="", + serial_number="1020301376", + devices=[], + entities=[ + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_current", + friendly_name="InWall Outlet-0394DE Current", + unique_id="00:00:00:00:00:00_1_13_18", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state="0.03", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_power", + friendly_name="InWall Outlet-0394DE Power", + unique_id="00:00:00:00:00:00_1_13_19", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=POWER_WATT, + state="0.8", + ), + EntityTestInfo( + 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}, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state="379.69299", + ), + EntityTestInfo( + entity_id="switch.inwall_outlet_0394de_outlet_a", + friendly_name="InWall Outlet-0394DE Outlet A", + unique_id="00:00:00:00:00:00_1_13", + state="on", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_current_2", + friendly_name="InWall Outlet-0394DE Current", + unique_id="00:00:00:00:00:00_1_25_30", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state="0.05", + ), + EntityTestInfo( + entity_id="sensor.inwall_outlet_0394de_power_2", + friendly_name="InWall Outlet-0394DE Power", + unique_id="00:00:00:00:00:00_1_25_31", + capabilities={"state_class": SensorStateClass.MEASUREMENT}, + unit_of_measurement=POWER_WATT, + state="0.8", + ), + EntityTestInfo( + 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}, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state="175.85001", + ), + EntityTestInfo( + entity_id="switch.inwall_outlet_0394de_outlet_b", + friendly_name="InWall Outlet-0394DE Outlet B", + unique_id="00:00:00:00:00:00_1_25", + state="on", + ), + ], + ), + ) + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() await assert_devices_and_entities_created( hass, diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index d9af858de9f..1cdd4ccb907 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -126,7 +126,7 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: friendly_name="HomeW Current Temperature", unique_id="00:00:00:00:00:00_1_16_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=TEMP_CELSIUS, + unit_of_measurement=UnitOfTemperature.CELSIUS, state="21.8", ), EntityTestInfo( diff --git a/tests/components/homekit_controller/specific_devices/test_eve_degree.py b/tests/components/homekit_controller/specific_devices/test_eve_degree.py index e866069ef16..10fcd8ede8e 100644 --- a/tests/components/homekit_controller/specific_devices/test_eve_degree.py +++ b/tests/components/homekit_controller/specific_devices/test_eve_degree.py @@ -1,7 +1,12 @@ """Make sure that Eve Degree (via Eve Extend) is enumerated properly.""" from homeassistant.components.number import NumberMode from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, EntityCategory, UnitOfPressure +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfPressure, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from ..common import ( @@ -36,7 +41,7 @@ async def test_eve_degree_setup(hass: HomeAssistant) -> None: unique_id="00:00:00:00:00:00_1_22", friendly_name="Eve Degree AA11 Temperature", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=TEMP_CELSIUS, + unit_of_measurement=UnitOfTemperature.CELSIUS, state="22.7719116210938", ), EntityTestInfo( diff --git a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py new file mode 100644 index 00000000000..5bb7003e58b --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py @@ -0,0 +1,51 @@ +"""Tests for handling accessories on a Homespan esp32 daikin bridge.""" +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.core import HomeAssistant + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_homespan_daikin_bridge_setup(hass: HomeAssistant) -> None: + """Test that aHomespan esp32 daikin bridge can be correctly setup in HA via HomeKit.""" + accessories = await setup_accessories_from_file(hass, "homespan_daikin_bridge.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Air Conditioner", + model="Daikin-fwec3a-esp32-homekit-bridge", + manufacturer="Garzola Marco", + sw_version="1.0.0", + hw_version="1.0.0", + serial_number="00000001", + devices=[], + entities=[ + EntityTestInfo( + entity_id="climate.air_conditioner_slaveid_1", + friendly_name="Air Conditioner SlaveID 1", + unique_id="00:00:00:00:00:00_1_9", + supported_features=( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + ), + capabilities={ + "hvac_modes": ["heat_cool", "heat", "cool", "off"], + "min_temp": 18, + "max_temp": 32, + "target_temp_step": 0.5, + "fan_modes": ["off", "low", "medium", "high"], + }, + state="cool", + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py index ca9c6cecde5..48828a2a6ad 100644 --- a/tests/components/homekit_controller/specific_devices/test_mysa_living.py +++ b/tests/components/homekit_controller/specific_devices/test_mysa_living.py @@ -1,7 +1,7 @@ """Make sure that Mysa Living is enumerated properly.""" from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import PERCENTAGE, TEMP_CELSIUS +from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from ..common import ( @@ -55,7 +55,7 @@ async def test_mysa_living_setup(hass: HomeAssistant) -> None: entity_id="sensor.mysa_85dda9_current_temperature", friendly_name="Mysa-85dda9 Current Temperature", unique_id="00:00:00:00:00:00_1_20_25", - unit_of_measurement=TEMP_CELSIUS, + unit_of_measurement=UnitOfTemperature.CELSIUS, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="24.1", ), diff --git a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py index 384b1d49d78..854de4b89d8 100644 --- a/tests/components/homekit_controller/specific_devices/test_velux_gateway.py +++ b/tests/components/homekit_controller/specific_devices/test_velux_gateway.py @@ -7,7 +7,7 @@ from homeassistant.components.sensor import SensorStateClass from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, - TEMP_CELSIUS, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -73,7 +73,7 @@ async def test_velux_cover_setup(hass: HomeAssistant) -> None: friendly_name="VELUX Sensor Temperature sensor", capabilities={"state_class": SensorStateClass.MEASUREMENT}, unique_id="00:00:00:00:00:00_2_8", - unit_of_measurement=TEMP_CELSIUS, + unit_of_measurement=UnitOfTemperature.CELSIUS, state="18.9", ), EntityTestInfo( diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 27c675b78ec..0f6a3633bd4 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -691,6 +691,9 @@ def create_heater_cooler_service(accessory): char = service.add_char(CharacteristicsTypes.SWING_MODE) char.value = 0 + char = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + char.value = 100 + # Test heater-cooler devices def create_heater_cooler_service_min_max(accessory): @@ -867,6 +870,103 @@ async def test_heater_cooler_change_thermostat_temperature( ) +async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> None: + """Test that we can change the target fan speed.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "low"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 33, + }, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "medium"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 66, + }, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "high"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + +async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None: + """Test that we can read the state of a HomeKit thermostat accessory.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + # Simulate that fan speed is off + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 0, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "off" + + # Simulate that fan speed is low + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 33, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "low" + + # Simulate that fan speed is medium + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 66, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "medium" + + # Simulate that fan speed is high + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "high" + + async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 5695077475f..e5949978215 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -97,10 +97,12 @@ async def test_migrate_device_id_no_serial_skip_if_other_owner( Create a device registry entry that needs migrate, but belongs to a different config entry. It should be ignored. """ + entry = MockConfigEntry() + entry.add_to_hass(hass) device_registry = dr.async_get(hass) bridge = device_registry.async_get_or_create( - config_entry_id="XX", + config_entry_id=entry.entry_id, identifiers=variant.before, manufacturer="RYSE Inc.", model="RYSE SmartBridge", @@ -115,7 +117,7 @@ async def test_migrate_device_id_no_serial_skip_if_other_owner( bridge = device_registry.async_get(bridge.id) assert bridge.identifiers == variant.before - assert bridge.config_entries == {"XX"} + assert bridge.config_entries == {entry.entry_id} @pytest.mark.parametrize("variant", DEVICE_MIGRATION_TESTS) diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index c7e5005446f..41b6a9fc7dc 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -14,10 +14,7 @@ from homeassistant.setup import async_setup_component from .common import setup_test_component -from tests.common import ( - async_get_device_automations, - async_mock_service, -) +from tests.common import async_get_device_automations, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index a586d5fe27d..b042e3daa6c 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -59,7 +59,12 @@ async def test_hmip_heating_group_heat( assert ha_state.attributes["temperature"] == 5.0 assert ha_state.attributes["current_humidity"] == 47 assert ha_state.attributes[ATTR_PRESET_MODE] == "STD" - assert ha_state.attributes[ATTR_PRESET_MODES] == [PRESET_BOOST, "STD", "Winter"] + assert ha_state.attributes[ATTR_PRESET_MODES] == [ + PRESET_BOOST, + PRESET_ECO, + "STD", + "Winter", + ] service_call_counter = len(hmip_device.mock_calls) @@ -219,6 +224,21 @@ async def test_hmip_heating_group_heat( # Only fire event from last async_manipulate_test_data available. assert hmip_device.mock_calls[-1][0] == "fire_update_event" + assert ha_state.state == HVACMode.AUTO + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": PRESET_ECO}, + blocking=True, + ) + assert len(hmip_device.mock_calls) == service_call_counter + 25 + assert hmip_device.mock_calls[-1][0] == "set_control_mode" + assert hmip_device.mock_calls[-1][1] == ("ECO",) + await async_manipulate_test_data(hass, hmip_device, "controlMode", "ECO") + ha_state = hass.states.get(entity_id) + assert ha_state.attributes[ATTR_PRESET_MODE] == PRESET_ECO + assert ha_state.state == HVACMode.AUTO + await async_manipulate_test_data(hass, hmip_device, "floorHeatingMode", "RADIATOR") await async_manipulate_test_data(hass, hmip_device, "valvePosition", 0.1) ha_state = hass.states.get(entity_id) @@ -376,7 +396,12 @@ async def test_hmip_heating_group_heat_with_switch( assert ha_state.attributes["temperature"] == 5.0 assert ha_state.attributes["current_humidity"] == 43 assert ha_state.attributes[ATTR_PRESET_MODE] == "STD" - assert ha_state.attributes[ATTR_PRESET_MODES] == [PRESET_BOOST, "STD", "P2"] + assert ha_state.attributes[ATTR_PRESET_MODES] == [ + PRESET_BOOST, + PRESET_ECO, + "STD", + "P2", + ] async def test_hmip_heating_group_heat_with_radiator( @@ -401,7 +426,11 @@ async def test_hmip_heating_group_heat_with_radiator( assert ha_state.attributes["max_temp"] == 30.0 assert ha_state.attributes["temperature"] == 5.0 assert ha_state.attributes[ATTR_PRESET_MODE] is None - assert ha_state.attributes[ATTR_PRESET_MODES] == [PRESET_NONE, PRESET_BOOST] + assert ha_state.attributes[ATTR_PRESET_MODES] == [ + PRESET_NONE, + PRESET_BOOST, + PRESET_ECO, + ] async def test_hmip_climate_services( diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index d84fe690df6..24842ab8beb 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -244,13 +244,13 @@ async def test_hmip_reset_energy_counter_services( blocking=True, ) assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 2 # pylint: disable=W0212 + assert len(hmip_device._connection.mock_calls) == 2 await hass.services.async_call( "homematicip_cloud", "reset_energy_counter", {"entity_id": "all"}, blocking=True ) assert hmip_device.mock_calls[-1][0] == "reset_energy_counter" - assert len(hmip_device._connection.mock_calls) == 4 # pylint: disable=W0212 + assert len(hmip_device._connection.mock_calls) == 4 async def test_hmip_multi_area_device( diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index bedd4290944..8406d76803a 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -121,7 +121,7 @@ def device_with_outdoor_sensor(): "hasFan": False, } mock_device.system_mode = "off" - mock_device.name = "device1" + mock_device.name = "device3" mock_device.current_temperature = CURRENTTEMPERATURE mock_device.mac_address = "macaddress1" mock_device.temperature_unit = "C" diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 4d6989d79e8..92caa29b71f 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -54,7 +54,9 @@ async def test_no_thermostat_options( """Test the setup of the climate entities when there are no additional options available.""" device._data = {} await init_integration(hass, config_entry) - assert len(hass.states.async_all()) == 1 + assert hass.states.get("climate.device1") + assert hass.states.get("sensor.device1_temperature") + assert hass.states.get("sensor.device1_humidity") async def test_static_attributes( @@ -1010,8 +1012,8 @@ async def test_async_update_errors( await init_integration(hass, config_entry) - device.refresh.side_effect = aiosomecomfort.SomeComfortError - client.login.side_effect = aiosomecomfort.SomeComfortError + device.refresh.side_effect = aiosomecomfort.UnauthorizedError + client.login.side_effect = aiosomecomfort.AuthError entity_id = f"climate.{device.name}" state = hass.states.get(entity_id) assert state.state == "off" @@ -1037,6 +1039,28 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" + device.refresh.side_effect = aiosomecomfort.UnexpectedResponse + client.login.side_effect = None + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + + device.refresh.side_effect = [aiosomecomfort.UnauthorizedError, None] + client.login.side_effect = None + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + # "reload integration" test device.refresh.side_effect = aiosomecomfort.SomeComfortError client.login.side_effect = aiosomecomfort.AuthError @@ -1046,9 +1070,8 @@ async def test_async_update_errors( ) await hass.async_block_till_done() - entity_id = f"climate.{device.name}" state = hass.states.get(entity_id) - assert state.state == "unavailable" + assert state.state == "off" device.refresh.side_effect = ClientConnectionError async_fire_time_changed( @@ -1057,7 +1080,6 @@ async def test_async_update_errors( ) await hass.async_block_till_done() - entity_id = f"climate.{device.name}" state = hass.states.get(entity_id) assert state.state == "unavailable" diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 36c94c83f31..f7629fa958e 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -27,7 +27,9 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) - await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.async_entity_ids_count() == 1 + assert ( + hass.states.async_entity_ids_count() == 3 + ) # 1 climate entity; 2 sensor entities async def test_setup_multiple_thermostats( @@ -39,7 +41,9 @@ async def test_setup_multiple_thermostats( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.async_entity_ids_count() == 2 + assert ( + hass.states.async_entity_ids_count() == 6 + ) # 2 climate entities; 4 sensor entities async def test_setup_multiple_thermostats_with_same_deviceid( @@ -58,7 +62,9 @@ async def test_setup_multiple_thermostats_with_same_deviceid( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert hass.states.async_entity_ids_count() == 1 + assert ( + hass.states.async_entity_ids_count() == 3 + ) # 1 climate entity; 2 sensor entities assert "Platform honeywell does not generate unique IDs" not in caplog.text diff --git a/tests/components/honeywell/test_sensor.py b/tests/components/honeywell/test_sensor.py index c40c90131a8..b286132a40f 100644 --- a/tests/components/honeywell/test_sensor.py +++ b/tests/components/honeywell/test_sensor.py @@ -26,8 +26,38 @@ async def test_outdoor_sensor( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - temperature_state = hass.states.get("sensor.device1_outdoor_temperature") - humidity_state = hass.states.get("sensor.device1_outdoor_humidity") + temperature_state = hass.states.get("sensor.device3_outdoor_temperature") + humidity_state = hass.states.get("sensor.device3_outdoor_humidity") + + assert temperature_state + assert humidity_state + assert temperature_state.state == temp + assert humidity_state.state == "25" + + +@pytest.mark.parametrize(("unit", "temp"), [("C", "5"), ("F", "-15")]) +async def test_indoor_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + location: Location, + device: Device, + unit, + temp, +) -> None: + """Test indoor temperature sensor with no outdoor sensors.""" + device.temperature_unit = unit + device.current_temperature = 5 + device.current_humidity = 25 + location.devices_by_id[device.deviceid] = device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.device1_outdoor_temperature") is None + assert hass.states.get("sensor.device1_outdoor_humidity") is None + + temperature_state = hass.states.get("sensor.device1_temperature") + humidity_state = hass.states.get("sensor.device1_humidity") assert temperature_state assert humidity_state diff --git a/tests/components/http/test_headers.py b/tests/components/http/test_headers.py new file mode 100644 index 00000000000..16b897b9f99 --- /dev/null +++ b/tests/components/http/test_headers.py @@ -0,0 +1,66 @@ +"""Test headers middleware.""" +from http import HTTPStatus + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized + +from homeassistant.components.http.headers import setup_headers + +from tests.typing import ClientSessionGenerator + + +async def mock_handler(_: web.Request) -> web.Response: + """Return OK.""" + return web.Response(text="OK") + + +async def mock_handler_error(_: web.Request) -> web.Response: + """Return Unauthorized.""" + raise HTTPUnauthorized(text="Ah ah ah, you didn't say the magic word") + + +async def test_headers_added(aiohttp_client: ClientSessionGenerator) -> None: + """Test that headers are being added on each request.""" + app = web.Application() + app.router.add_get("/", mock_handler) + app.router.add_get("/error", mock_handler_error) + + setup_headers(app, use_x_frame_options=True) + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/") + + assert resp.status == HTTPStatus.OK + assert resp.headers["Referrer-Policy"] == "no-referrer" + assert resp.headers["Server"] == "" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "SAMEORIGIN" + + resp = await mock_api_client.get("/error") + + assert resp.status == HTTPStatus.UNAUTHORIZED + assert resp.headers["Referrer-Policy"] == "no-referrer" + assert resp.headers["Server"] == "" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "SAMEORIGIN" + + +async def test_allow_framing(aiohttp_client: ClientSessionGenerator) -> None: + """Test that we allow framing when disabled.""" + app = web.Application() + app.router.add_get("/", mock_handler) + app.router.add_get("/error", mock_handler_error) + + setup_headers(app, use_x_frame_options=False) + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/") + + assert resp.status == HTTPStatus.OK + assert "X-Frame-Options" not in resp.headers + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/error") + + assert resp.status == HTTPStatus.UNAUTHORIZED + assert "X-Frame-Options" not in resp.headers diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 8f9fff79580..3fc8d7689d6 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -85,11 +85,13 @@ class TestView(http.HomeAssistantView): async def test_registering_view_while_running( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, aiohttp_unused_port + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, unused_tcp_port_factory ) -> None: """Test that we can register a view while the server is running.""" await async_setup_component( - hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: aiohttp_unused_port()}} + hass, + http.DOMAIN, + {http.DOMAIN: {http.CONF_SERVER_PORT: unused_tcp_port_factory()}}, ) await hass.async_start() @@ -443,11 +445,11 @@ async def test_cors_defaults(hass: HomeAssistant) -> None: async def test_storing_config( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, aiohttp_unused_port + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, unused_tcp_port_factory ) -> None: """Test that we store last working config.""" config = { - http.CONF_SERVER_PORT: aiohttp_unused_port(), + http.CONF_SERVER_PORT: unused_tcp_port_factory(), "use_x_forwarded_for": True, "trusted_proxies": ["192.168.1.100"], } diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index 5469b7ebfa7..9e4353d7e61 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -75,7 +75,7 @@ async def test_bad_requests( fail_on_query_string, aiohttp_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - loop, + 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 loop.run_in_executor( + resp = await event_loop.run_in_executor( None, http.request, "GET", @@ -126,7 +126,7 @@ async def test_bad_requests_with_unsafe_bytes( fail_on_query_string, aiohttp_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - loop, + event_loop, ) -> None: """Test request with unsafe bytes in their URLs.""" app = web.Application() @@ -144,7 +144,7 @@ async def test_bad_requests_with_unsafe_bytes( man_params = "" http = urllib3.PoolManager() - resp = await loop.run_in_executor( + resp = await event_loop.run_in_executor( None, http.request, "GET", diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index e89f53af73a..e79fce7ab13 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -11,10 +11,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import setup_platform -from tests.common import ( - async_capture_events, - async_get_device_automations, -) +from tests.common import async_capture_events, async_get_device_automations async def test_hue_event( diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index 4dbb104357d..a3779c6b0e3 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -1,8 +1,5 @@ """Philips Hue Event platform tests for V2 bridge/api.""" -from homeassistant.components.event import ( - ATTR_EVENT_TYPE, - ATTR_EVENT_TYPES, -) +from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES from homeassistant.core import HomeAssistant from .conftest import setup_platform diff --git a/tests/components/hue/test_migration.py b/tests/components/hue/test_migration.py index b03834c3249..ef51c2a2f89 100644 --- a/tests/components/hue/test_migration.py +++ b/tests/components/hue/test_migration.py @@ -48,6 +48,7 @@ async def test_light_entity_migration( ) -> None: """Test if entity schema for lights migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) @@ -92,6 +93,7 @@ async def test_sensor_entity_migration( ) -> None: """Test if entity schema for sensors migrates from v1 to v2.""" config_entry = mock_bridge_v2.config_entry = mock_config_entry_v2 + config_entry.add_to_hass(hass) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) diff --git a/tests/components/hue/test_sensor_v1.py b/tests/components/hue/test_sensor_v1.py index d5ac8406f24..1edaf18774f 100644 --- a/tests/components/hue/test_sensor_v1.py +++ b/tests/components/hue/test_sensor_v1.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import Mock import aiohue +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import hue from homeassistant.components.hue.const import ATTR_HUE_EVENT @@ -10,7 +11,6 @@ from homeassistant.components.hue.v1 import sensor_base from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get -from homeassistant.util import dt as dt_util from .conftest import create_mock_bridge, setup_platform @@ -448,7 +448,9 @@ async def test_update_unauthorized(hass: HomeAssistant, mock_bridge_v1) -> None: assert len(mock_bridge_v1.handle_unauthorized_error.mock_calls) == 1 -async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> None: +async def test_hue_events( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_bridge_v1, device_reg +) -> None: """Test that hue remotes fire events when pressed.""" mock_bridge_v1.mock_sensor_responses.append(SENSOR_RESPONSE) @@ -475,9 +477,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 2 @@ -504,9 +505,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 3 @@ -530,9 +530,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 4 @@ -575,9 +574,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(mock_bridge_v1.mock_requests) == 5 @@ -589,9 +587,8 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No mock_bridge_v1.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - async_fire_time_changed( - hass, dt_util.utcnow() + sensor_base.SensorManager.SCAN_INTERVAL - ) + freezer.tick(sensor_base.SensorManager.SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() hue_aurora_device = device_reg.async_get_device( diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index f9cef677ead..97b705ef731 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -763,7 +763,6 @@ async def test_options_priority(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: TEST_ENTITY_ID_1}, blocking=True, ) - # pylint: disable-next=unsubscriptable-object assert client.async_send_set_color.call_args[1][CONF_PRIORITY] == new_priority diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index a59761d1c74..40b4c47a3c9 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -23,9 +23,9 @@ async def setup_homeassistant(hass: HomeAssistant): @pytest.fixture -def aiohttp_unused_port(event_loop, aiohttp_unused_port, socket_enabled): +def aiohttp_unused_port_factory(event_loop, unused_tcp_port_factory, socket_enabled): """Return aiohttp_unused_port and allow opening sockets.""" - return aiohttp_unused_port + return unused_tcp_port_factory def get_url(hass): @@ -34,12 +34,12 @@ def get_url(hass): return f"{hass.config.internal_url}{state.attributes.get(ATTR_ENTITY_PICTURE)}" -async def setup_image_processing(hass, aiohttp_unused_port): +async def setup_image_processing(hass, aiohttp_unused_port_factory): """Set up things to be run when tests are started.""" await async_setup_component( hass, http.DOMAIN, - {http.DOMAIN: {http.CONF_SERVER_PORT: aiohttp_unused_port()}}, + {http.DOMAIN: {http.CONF_SERVER_PORT: aiohttp_unused_port_factory()}}, ) config = {ip.DOMAIN: {"platform": "test"}, "camera": {"platform": "demo"}} @@ -85,11 +85,11 @@ async def test_setup_component_with_service(hass: HomeAssistant) -> None: async def test_get_image_from_camera( mock_camera_read, hass: HomeAssistant, - aiohttp_unused_port, + aiohttp_unused_port_factory, enable_custom_integrations: None, ) -> None: """Grab an image from camera entity.""" - await setup_image_processing(hass, aiohttp_unused_port) + await setup_image_processing(hass, aiohttp_unused_port_factory) common.async_scan(hass, entity_id="image_processing.test") await hass.async_block_till_done() @@ -108,11 +108,11 @@ async def test_get_image_from_camera( async def test_get_image_without_exists_camera( mock_image, hass: HomeAssistant, - aiohttp_unused_port, + aiohttp_unused_port_factory, enable_custom_integrations: None, ) -> None: """Try to get image without exists camera.""" - await setup_image_processing(hass, aiohttp_unused_port) + await setup_image_processing(hass, aiohttp_unused_port_factory) hass.states.async_remove("camera.demo_camera") diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b9512da0278..b4ee11ba787 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -470,6 +470,8 @@ async def test_reset_last_message( ) -> None: """Test receiving a message successfully.""" event = asyncio.Event() # needed for pushed coordinator to make a new loop + idle_start_future = asyncio.Future() + idle_start_future.set_result(None) async def _sleep_till_event() -> None: """Simulate imap server waiting for pushes message and keep the push loop going. @@ -479,10 +481,10 @@ async def test_reset_last_message( nonlocal event await event.wait() event.clear() - mock_imap_protocol.idle_start.return_value = AsyncMock()() + mock_imap_protocol.idle_start = AsyncMock(return_value=idle_start_future) # Make sure we make another cycle (needed for pushed coordinator) - mock_imap_protocol.idle_start.return_value = AsyncMock()() + mock_imap_protocol.idle_start = AsyncMock(return_value=idle_start_future) # Mock we wait till we push an update (needed for pushed coordinator) mock_imap_protocol.wait_server_push.side_effect = _sleep_till_event diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index a1234b7a470..b6d68714af5 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -8,12 +8,7 @@ import pytest import homeassistant.components.influxdb as influxdb from homeassistant.components.influxdb.const import DEFAULT_BUCKET -from homeassistant.const import ( - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_STANDBY, -) +from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON, STATE_STANDBY from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.setup import async_setup_component diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py index 1c4e2abf123..15f529babd8 100644 --- a/tests/components/insteon/test_init.py +++ b/tests/components/insteon/test_init.py @@ -44,7 +44,6 @@ async def test_setup_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - # pylint: disable-next=no-member assert insteon.devices.async_save.call_count == 1 assert mock_close.called diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index a552d401681..0c2744dd654 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -685,6 +685,7 @@ async def test_device_id(hass: HomeAssistant) -> None: entity_registry = er.async_get(hass) source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, identifiers={("sensor", "identifier_test")}, diff --git a/tests/components/ios/test_init.py b/tests/components/ios/test_init.py index 67c8bbde2cc..9586bd3c011 100644 --- a/tests/components/ios/test_init.py +++ b/tests/components/ios/test_init.py @@ -7,7 +7,7 @@ from homeassistant.components import ios from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import mock_component, mock_coro +from tests.common import mock_component @pytest.fixture(autouse=True) @@ -28,7 +28,7 @@ async def test_creating_entry_sets_up_sensor(hass: HomeAssistant) -> None: """Test setting up iOS loads the sensor component.""" with patch( "homeassistant.components.ios.sensor.async_setup_entry", - return_value=mock_coro(True), + return_value=True, ) as mock_setup: assert await async_setup_component(hass, ios.DOMAIN, {ios.DOMAIN: {}}) await hass.async_block_till_done() @@ -39,7 +39,8 @@ async def test_creating_entry_sets_up_sensor(hass: HomeAssistant) -> None: async def test_configuring_ios_creates_entry(hass: HomeAssistant) -> None: """Test that specifying config will create an entry.""" with patch( - "homeassistant.components.ios.async_setup_entry", return_value=mock_coro(True) + "homeassistant.components.ios.async_setup_entry", + return_value=True, ) as mock_setup: await async_setup_component(hass, ios.DOMAIN, {"ios": {"push": {}}}) await hass.async_block_till_done() @@ -50,7 +51,8 @@ async def test_configuring_ios_creates_entry(hass: HomeAssistant) -> None: async def test_not_configuring_ios_not_creates_entry(hass: HomeAssistant) -> None: """Test that no config will not create an entry.""" with patch( - "homeassistant.components.ios.async_setup_entry", return_value=mock_coro(True) + "homeassistant.components.ios.async_setup_entry", + return_value=True, ) as mock_setup: await async_setup_component(hass, ios.DOMAIN, {"foo": "bar"}) await hass.async_block_till_done() diff --git a/tests/components/iotawatt/test_sensor.py b/tests/components/iotawatt/test_sensor.py index d9017955c75..5646115f59a 100644 --- a/tests/components/iotawatt/test_sensor.py +++ b/tests/components/iotawatt/test_sensor.py @@ -1,6 +1,8 @@ """Test setting up sensors.""" from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -15,14 +17,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from . import INPUT_SENSOR, OUTPUT_SENSOR from tests.common import async_fire_time_changed -async def test_sensor_type_input(hass: HomeAssistant, mock_iotawatt) -> None: +async def test_sensor_type_input( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_iotawatt +) -> None: """Test input sensors work.""" assert await async_setup_component(hass, "iotawatt", {}) await hass.async_block_till_done() @@ -31,7 +34,8 @@ async def test_sensor_type_input(hass: HomeAssistant, mock_iotawatt) -> None: # Discover this sensor during a regular update. mock_iotawatt.getSensors.return_value["sensors"]["my_sensor_key"] = INPUT_SENSOR - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 1 @@ -47,13 +51,16 @@ async def test_sensor_type_input(hass: HomeAssistant, mock_iotawatt) -> None: assert state.attributes["type"] == "Input" mock_iotawatt.getSensors.return_value["sensors"].pop("my_sensor_key") - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.my_sensor") is None -async def test_sensor_type_output(hass: HomeAssistant, mock_iotawatt) -> None: +async def test_sensor_type_output( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_iotawatt +) -> None: """Tests the sensor type of Output.""" mock_iotawatt.getSensors.return_value["sensors"][ "my_watthour_sensor_key" @@ -73,7 +80,8 @@ async def test_sensor_type_output(hass: HomeAssistant, mock_iotawatt) -> None: assert state.attributes["type"] == "Output" mock_iotawatt.getSensors.return_value["sensors"].pop("my_watthour_sensor_key") - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.my_watthour_sensor") is None diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index ba172fc7bb8..827481c60de 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -1,6 +1,6 @@ """Tests for the IPMA component.""" from collections import namedtuple -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME @@ -87,7 +87,7 @@ class MockLocation: return [ Forecast( "7.7", - datetime(2020, 1, 15, 1, 0, 0, tzinfo=timezone.utc), + datetime(2020, 1, 15, 1, 0, 0, tzinfo=UTC), 1, "86.9", 12.0, @@ -101,7 +101,7 @@ class MockLocation: ), Forecast( "5.7", - datetime(2020, 1, 15, 2, 0, 0, tzinfo=timezone.utc), + datetime(2020, 1, 15, 2, 0, 0, tzinfo=UTC), 1, "86.9", 12.0, diff --git a/tests/components/ipma/conftest.py b/tests/components/ipma/conftest.py new file mode 100644 index 00000000000..dda0e69d118 --- /dev/null +++ b/tests/components/ipma/conftest.py @@ -0,0 +1,36 @@ +"""Define test fixtures for IPMA.""" + +import pytest + +from homeassistant.components.ipma import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=config, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_NAME: "Home", + CONF_LATITUDE: 0, + CONF_LONGITUDE: 0, + } + + +@pytest.fixture(name="setup_config_entry") +async def setup_config_entry_fixture(hass, config_entry): + """Define a fixture to set up ipma.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ipma/snapshots/test_weather.ambr b/tests/components/ipma/snapshots/test_weather.ambr new file mode 100644 index 00000000000..92e1d1a91b5 --- /dev/null +++ b/tests/components/ipma/snapshots/test_weather.ambr @@ -0,0 +1,104 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 16, 0, 0), + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'rainy', + 'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc), + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]), + }) +# --- +# name: test_forecast_subscription[daily] + list([ + dict({ + 'condition': 'rainy', + 'datetime': '2020-01-16T00:00:00', + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]) +# --- +# name: test_forecast_subscription[daily].1 + list([ + dict({ + 'condition': 'rainy', + 'datetime': '2020-01-16T00:00:00', + 'precipitation_probability': '100.0', + 'temperature': 16.2, + 'templow': 10.6, + 'wind_bearing': 'S', + 'wind_speed': 10.0, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'rainy', + 'datetime': '2020-01-15T01:00:00+00:00', + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-01-15T02:00:00+00:00', + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'rainy', + 'datetime': '2020-01-15T01:00:00+00:00', + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-01-15T02:00:00+00:00', + 'precipitation_probability': 80.0, + 'temperature': 12.0, + 'wind_bearing': 'S', + 'wind_speed': 32.7, + }), + ]) +# --- diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index f9d69ec41ae..aff8af16bc3 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -1,172 +1,112 @@ """Tests for IPMA config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import patch -from homeassistant.components.ipma import DOMAIN, config_flow -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from pyipma import IPMAException +import pytest + +from homeassistant.components.ipma.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.data_entry_flow import FlowResultType -from . import MockLocation - -from tests.common import MockConfigEntry, mock_registry +from tests.components.ipma import MockLocation -async def test_show_config_form() -> None: - """Test show configuration form.""" - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass +@pytest.fixture(name="ipma_setup", autouse=True) +def ipma_setup_fixture(request): + """Patch ipma setup entry.""" + with patch("homeassistant.components.ipma.async_setup_entry", return_value=True): + yield - result = await flow._show_config_form() + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test configuration form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] == "form" assert result["step_id"] == "user" - -async def test_show_config_form_default_values() -> None: - """Test show configuration form.""" - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - result = await flow._show_config_form(name="test", latitude="0", longitude="0") - - assert result["type"] == "form" - assert result["step_id"] == "user" - - -async def test_flow_with_home_location(hass: HomeAssistant) -> None: - """Test config flow . - - Tests the flow when a default location is configured - then it should return a form with default values - """ - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - hass.config.location_name = "Home" - hass.config.latitude = 1 - hass.config.longitude = 1 - - result = await flow.async_step_user() - assert result["type"] == "form" - assert result["step_id"] == "user" - - -async def test_flow_show_form() -> None: - """Test show form scenarios first time. - - Test when the form should show when no configurations exists - """ - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - with patch( - "homeassistant.components.ipma.config_flow.IpmaFlowHandler._show_config_form" - ) as config_form: - await flow.async_step_user() - assert len(config_form.mock_calls) == 1 - - -async def test_flow_entry_created_from_user_input() -> None: - """Test that create data from user input. - - Test when the form should show when no configurations exists - """ - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - test_data = {"name": "home", CONF_LONGITUDE: "0", CONF_LATITUDE: "0"} - - # Test that entry created when user_input name not exists - with patch( - "homeassistant.components.ipma.config_flow.IpmaFlowHandler._show_config_form" - ) as config_form, patch.object( - flow.hass.config_entries, - "async_entries", - return_value=[], - ) as config_entries: - result = await flow.async_step_user(user_input=test_data) - - assert result["type"] == "create_entry" - assert result["data"] == test_data - assert len(config_entries.mock_calls) == 1 - assert not config_form.mock_calls - - -async def test_flow_entry_config_entry_already_exists() -> None: - """Test that create data from user input and config_entry already exists. - - Test when the form should show when user puts existing name - in the config gui. Then the form should show with error - """ - hass = Mock() - flow = config_flow.IpmaFlowHandler() - flow.hass = hass - - test_data = {"name": "home", CONF_LONGITUDE: "0", CONF_LATITUDE: "0"} - - # Test that entry created when user_input name not exists - with patch( - "homeassistant.components.ipma.config_flow.IpmaFlowHandler._show_config_form" - ) as config_form, patch.object( - flow.hass.config_entries, "async_entries", return_value={"home": test_data} - ) as config_entries: - await flow.async_step_user(user_input=test_data) - - assert len(config_form.mock_calls) == 1 - assert len(config_entries.mock_calls) == 1 - assert len(flow._errors) == 1 - - -async def test_config_entry_migration(hass: HomeAssistant) -> None: - """Tests config entry without mode in unique_id can be migrated.""" - ipma_entry = MockConfigEntry( - domain=DOMAIN, - title="Home", - data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "daily"}, - ) - ipma_entry.add_to_hass(hass) - - ipma_entry2 = MockConfigEntry( - domain=DOMAIN, - title="Home", - data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "hourly"}, - ) - ipma_entry2.add_to_hass(hass) - - mock_registry( - hass, - { - "weather.hometown": er.RegistryEntry( - entity_id="weather.hometown", - unique_id="0, 0", - platform="ipma", - config_entry_id=ipma_entry.entry_id, - ), - "weather.hometown_2": er.RegistryEntry( - entity_id="weather.hometown_2", - unique_id="0, 0, hourly", - platform="ipma", - config_entry_id=ipma_entry.entry_id, - ), - }, - ) - + test_data = { + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } with patch( "pyipma.location.Location.get", return_value=MockLocation(), ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + test_data, + ) - ent_reg = er.async_get(hass) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "HomeTown" + assert result["data"] == { + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } - weather_home = ent_reg.async_get("weather.hometown") - assert weather_home.unique_id == "0, 0, daily" - weather_home2 = ent_reg.async_get("weather.hometown_2") - assert weather_home2.unique_id == "0, 0, hourly" +async def test_config_flow_failures(hass: HomeAssistant) -> None: + """Test config flow with failures.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + test_data = { + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } + with patch( + "pyipma.location.Location.get", + side_effect=IPMAException(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + test_data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + with patch( + "pyipma.location.Location.get", + return_value=MockLocation(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + test_data, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "HomeTown" + assert result["data"] == { + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } + + +async def test_flow_entry_already_exists(hass: HomeAssistant, config_entry) -> None: + """Test user input for config_entry that already exists. + + Test when the form should show when user puts existing location + in the config gui. Then the form should show with error. + """ + test_data = { + CONF_NAME: "Home", + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=test_data + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index 285f7ceacb7..71884e0c82e 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -1,9 +1,12 @@ """The tests for the IPMA weather component.""" -from datetime import datetime +import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.ipma.const import MIN_TIME_BETWEEN_UPDATES from homeassistant.components.weather import ( ATTR_FORECAST, ATTR_FORECAST_CONDITION, @@ -18,6 +21,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -25,6 +30,7 @@ from homeassistant.core import HomeAssistant from . import MockLocation from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator TEST_CONFIG = { "name": "HomeTown", @@ -91,7 +97,7 @@ async def test_daily_forecast(hass: HomeAssistant) -> None: assert state.state == "rainy" forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_TIME) == datetime(2020, 1, 16, 0, 0, 0) + assert forecast.get(ATTR_FORECAST_TIME) == datetime.datetime(2020, 1, 16, 0, 0, 0) assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy" assert forecast.get(ATTR_FORECAST_TEMP) == 16.2 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6 @@ -144,3 +150,93 @@ async def test_failed_get_observation_forecast(hass: HomeAssistant) -> None: assert data.get(ATTR_WEATHER_WIND_SPEED) is None assert data.get(ATTR_WEATHER_WIND_BEARING) is None assert state.attributes.get("friendly_name") == "HomeTown" + + +async def test_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + + with patch( + "pyipma.location.Location.get", + return_value=MockLocation(), + ): + entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.hometown", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.hometown", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + with patch( + "pyipma.location.Location.get", + return_value=MockLocation(), + ): + entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": "weather.hometown", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 == snapshot + + freezer.tick(MIN_TIME_BETWEEN_UPDATES + datetime.timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 == snapshot diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index ebebd18bc72..5992b928f63 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -4,7 +4,12 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components.sensor import ATTR_OPTIONS -from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE +from homeassistant.const import ( + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -66,8 +71,10 @@ async def test_sensors( assert state.state == "2019-11-11T09:10:02+00:00" entry = entity_registry.async_get("sensor.test_ha_1000_series_uptime") + assert entry assert entry.unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251_uptime" + assert entry.entity_category == EntityCategory.DIAGNOSTIC async def test_disabled_by_default_sensors( diff --git a/tests/components/iqvia/conftest.py b/tests/components/iqvia/conftest.py index b6ac1724885..075d7249d36 100644 --- a/tests/components/iqvia/conftest.py +++ b/tests/components/iqvia/conftest.py @@ -13,7 +13,12 @@ from tests.common import MockConfigEntry, load_fixture @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_ZIP_CODE], data=config) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=config[CONF_ZIP_CODE], + data=config, + entry_id="690ac4b7e99855fc5ee7b987a758d5cb", + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..49006716fb3 --- /dev/null +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -0,0 +1,363 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'allergy_average_forecasted': dict({ + 'ForecastDate': '2018-06-12T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Index': 6.6, + 'Period': '2018-06-12T13:47:12.897', + }), + dict({ + 'Index': 6.3, + 'Period': '2018-06-13T13:47:12.897', + }), + dict({ + 'Index': 7.6, + 'Period': '2018-06-14T13:47:12.897', + }), + dict({ + 'Index': 7.6, + 'Period': '2018-06-15T13:47:12.897', + }), + dict({ + 'Index': 7.3, + 'Period': '2018-06-16T13:47:12.897', + }), + ]), + }), + 'Type': 'pollen', + }), + 'allergy_index': dict({ + 'ForecastDate': '2018-06-12T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Index': 7.2, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Genus': 'Juniperus', + 'LGID': 272, + 'Name': 'Juniper', + 'PlantType': 'Tree', + }), + dict({ + 'Genus': 'Grasses', + 'LGID': 346, + 'Name': 'Grasses', + 'PlantType': 'Grass', + }), + dict({ + 'Genus': 'Chenopods', + 'LGID': 63, + 'Name': 'Chenopods', + 'PlantType': 'Ragweed', + }), + ]), + 'Type': 'Yesterday', + }), + dict({ + 'Index': 6.6, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Genus': 'Juniperus', + 'LGID': 272, + 'Name': 'Juniper', + 'PlantType': 'Tree', + }), + dict({ + 'Genus': 'Grasses', + 'LGID': 346, + 'Name': 'Grasses', + 'PlantType': 'Grass', + }), + dict({ + 'Genus': 'Chenopods', + 'LGID': 63, + 'Name': 'Chenopods', + 'PlantType': 'Ragweed', + }), + ]), + 'Type': 'Today', + }), + dict({ + 'Index': 6.3, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Genus': 'Juniperus', + 'LGID': 272, + 'Name': 'Juniper', + 'PlantType': 'Tree', + }), + dict({ + 'Genus': 'Grasses', + 'LGID': 346, + 'Name': 'Grasses', + 'PlantType': 'Grass', + }), + dict({ + 'Genus': 'Chenopods', + 'LGID': 63, + 'Name': 'Chenopods', + 'PlantType': 'Ragweed', + }), + ]), + 'Type': 'Tomorrow', + }), + ]), + }), + 'Type': 'pollen', + }), + 'allergy_outlook': dict({ + 'Market': '**REDACTED**', + 'Outlook': 'The amount of pollen in the air for Wednesday...', + 'Season': 'Tree', + 'Trend': 'subsiding', + 'TrendID': 4, + 'ZIP': '**REDACTED**', + }), + 'asthma_average_forecasted': dict({ + 'ForecastDate': '2018-10-28T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Idx': '4.5', + 'Index': 4.5, + 'Period': '2018-10-28T05:45:01.45', + }), + dict({ + 'Idx': '4.7', + 'Index': 4.7, + 'Period': '2018-10-29T05:45:01.45', + }), + dict({ + 'Idx': '5.0', + 'Index': 5, + 'Period': '2018-10-30T05:45:01.45', + }), + dict({ + 'Idx': '5.2', + 'Index': 5.2, + 'Period': '2018-10-31T05:45:01.45', + }), + dict({ + 'Idx': '5.5', + 'Index': 5.5, + 'Period': '2018-11-01T05:45:01.45', + }), + ]), + }), + 'Type': 'asthma', + }), + 'asthma_index': dict({ + 'ForecastDate': '2018-10-29T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Idx': '4.1', + 'Index': 4.1, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Description': 'Ozone (O3) is a odorless, colorless ....', + 'LGID': 1, + 'Name': 'OZONE', + 'PPM': 42, + }), + dict({ + 'Description': 'Fine particles (PM2.5) are 2.5 ...', + 'LGID': 1, + 'Name': 'PM2.5', + 'PPM': 30, + }), + dict({ + 'Description': 'Coarse dust particles (PM10) are 2.5 ...', + 'LGID': 1, + 'Name': 'PM10', + 'PPM': 19, + }), + ]), + 'Type': 'Yesterday', + }), + dict({ + 'Idx': '4.5', + 'Index': 4.5, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + dict({ + 'Description': 'Fine particles (PM2.5) are 2.5 ...', + 'LGID': 3, + 'Name': 'PM2.5', + 'PPM': 105, + }), + dict({ + 'Description': 'Coarse dust particles (PM10) are 2.5 ...', + 'LGID': 2, + 'Name': 'PM10', + 'PPM': 65, + }), + dict({ + 'Description': 'Ozone (O3) is a odorless, colorless ...', + 'LGID': 1, + 'Name': 'OZONE', + 'PPM': 42, + }), + ]), + 'Type': 'Today', + }), + dict({ + 'Idx': '4.6', + 'Index': 4.6, + 'Period': '0001-01-01T00:00:00', + 'Triggers': list([ + ]), + 'Type': 'Tomorrow', + }), + ]), + }), + 'Type': 'asthma', + }), + 'disease_average_forecasted': dict({ + 'ForecastDate': '2018-06-12T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Index': 2.4, + 'Period': '2018-06-12T05:13:51.817', + }), + dict({ + 'Index': 2.5, + 'Period': '2018-06-13T05:13:51.817', + }), + dict({ + 'Index': 2.5, + 'Period': '2018-06-14T05:13:51.817', + }), + dict({ + 'Index': 2.5, + 'Period': '2018-06-15T05:13:51.817', + }), + ]), + }), + 'Type': 'cold', + }), + 'disease_index': dict({ + 'ForecastDate': '2019-04-07T00:00:00-04:00', + 'Location': dict({ + 'City': '**REDACTED**', + 'DisplayLocation': '**REDACTED**', + 'State': '**REDACTED**', + 'ZIP': '**REDACTED**', + 'periods': list([ + dict({ + 'Idx': '6.8', + 'Index': 6.8, + 'Period': '2019-04-06T00:00:00', + 'Triggers': list([ + dict({ + 'Description': 'Influenza', + 'Idx': '3.1', + 'Index': 3.1, + 'Name': 'Flu', + }), + dict({ + 'Description': 'High Fever', + 'Idx': '6.2', + 'Index': 6.2, + 'Name': 'Fever', + }), + dict({ + 'Description': 'Strep & Sore throat', + 'Idx': '5.2', + 'Index': 5.2, + 'Name': 'Strep', + }), + dict({ + 'Description': 'Cough', + 'Idx': '7.8', + 'Index': 7.8, + 'Name': 'Cough', + }), + ]), + 'Type': 'Yesterday', + }), + dict({ + 'Idx': '6.7', + 'Index': 6.7, + 'Period': '2019-04-07T03:52:58', + 'Triggers': list([ + dict({ + 'Description': 'Influenza', + 'Idx': '3.1', + 'Index': 3.1, + 'Name': 'Flu', + }), + dict({ + 'Description': 'High Fever', + 'Idx': '5.9', + 'Index': 5.9, + 'Name': 'Fever', + }), + dict({ + 'Description': 'Strep & Sore throat', + 'Idx': '5.1', + 'Index': 5.1, + 'Name': 'Strep', + }), + dict({ + 'Description': 'Cough', + 'Idx': '7.7', + 'Index': 7.7, + 'Name': 'Cough', + }), + ]), + 'Type': 'Today', + }), + ]), + }), + 'Type': 'cold', + }), + }), + 'entry': dict({ + 'data': dict({ + 'zip_code': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'iqvia', + 'entry_id': '690ac4b7e99855fc5ee7b987a758d5cb', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/iqvia/test_diagnostics.py b/tests/components/iqvia/test_diagnostics.py index 2acf37d6642..bde2af57447 100644 --- a/tests/components/iqvia/test_diagnostics.py +++ b/tests/components/iqvia/test_diagnostics.py @@ -1,5 +1,6 @@ """Test IQVIA diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -7,339 +8,15 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, config_entry, hass_client: ClientSessionGenerator, setup_iqvia + hass: HomeAssistant, + config_entry, + hass_client: ClientSessionGenerator, + setup_iqvia, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "iqvia", - "title": REDACTED, - "data": {"zip_code": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "allergy_average_forecasted": { - "Type": "pollen", - "ForecastDate": "2018-06-12T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - {"Period": "2018-06-12T13:47:12.897", "Index": 6.6}, - {"Period": "2018-06-13T13:47:12.897", "Index": 6.3}, - {"Period": "2018-06-14T13:47:12.897", "Index": 7.6}, - {"Period": "2018-06-15T13:47:12.897", "Index": 7.6}, - {"Period": "2018-06-16T13:47:12.897", "Index": 7.3}, - ], - "DisplayLocation": REDACTED, - }, - }, - "allergy_index": { - "Type": "pollen", - "ForecastDate": "2018-06-12T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - { - "Triggers": [ - { - "LGID": 272, - "Name": "Juniper", - "Genus": "Juniperus", - "PlantType": "Tree", - }, - { - "LGID": 346, - "Name": "Grasses", - "Genus": "Grasses", - "PlantType": "Grass", - }, - { - "LGID": 63, - "Name": "Chenopods", - "Genus": "Chenopods", - "PlantType": "Ragweed", - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Yesterday", - "Index": 7.2, - }, - { - "Triggers": [ - { - "LGID": 272, - "Name": "Juniper", - "Genus": "Juniperus", - "PlantType": "Tree", - }, - { - "LGID": 346, - "Name": "Grasses", - "Genus": "Grasses", - "PlantType": "Grass", - }, - { - "LGID": 63, - "Name": "Chenopods", - "Genus": "Chenopods", - "PlantType": "Ragweed", - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Today", - "Index": 6.6, - }, - { - "Triggers": [ - { - "LGID": 272, - "Name": "Juniper", - "Genus": "Juniperus", - "PlantType": "Tree", - }, - { - "LGID": 346, - "Name": "Grasses", - "Genus": "Grasses", - "PlantType": "Grass", - }, - { - "LGID": 63, - "Name": "Chenopods", - "Genus": "Chenopods", - "PlantType": "Ragweed", - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Tomorrow", - "Index": 6.3, - }, - ], - "DisplayLocation": REDACTED, - }, - }, - "allergy_outlook": { - "Market": REDACTED, - "ZIP": REDACTED, - "TrendID": 4, - "Trend": "subsiding", - "Outlook": "The amount of pollen in the air for Wednesday...", - "Season": "Tree", - }, - "asthma_average_forecasted": { - "Type": "asthma", - "ForecastDate": "2018-10-28T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - { - "Period": "2018-10-28T05:45:01.45", - "Index": 4.5, - "Idx": "4.5", - }, - { - "Period": "2018-10-29T05:45:01.45", - "Index": 4.7, - "Idx": "4.7", - }, - {"Period": "2018-10-30T05:45:01.45", "Index": 5, "Idx": "5.0"}, - { - "Period": "2018-10-31T05:45:01.45", - "Index": 5.2, - "Idx": "5.2", - }, - { - "Period": "2018-11-01T05:45:01.45", - "Index": 5.5, - "Idx": "5.5", - }, - ], - "DisplayLocation": REDACTED, - }, - }, - "asthma_index": { - "Type": "asthma", - "ForecastDate": "2018-10-29T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - { - "Triggers": [ - { - "LGID": 1, - "Name": "OZONE", - "PPM": 42, - "Description": ( - "Ozone (O3) is a odorless, colorless ...." - ), - }, - { - "LGID": 1, - "Name": "PM2.5", - "PPM": 30, - "Description": "Fine particles (PM2.5) are 2.5 ...", - }, - { - "LGID": 1, - "Name": "PM10", - "PPM": 19, - "Description": ( - "Coarse dust particles (PM10) are 2.5 ..." - ), - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Yesterday", - "Index": 4.1, - "Idx": "4.1", - }, - { - "Triggers": [ - { - "LGID": 3, - "Name": "PM2.5", - "PPM": 105, - "Description": "Fine particles (PM2.5) are 2.5 ...", - }, - { - "LGID": 2, - "Name": "PM10", - "PPM": 65, - "Description": ( - "Coarse dust particles (PM10) are 2.5 ..." - ), - }, - { - "LGID": 1, - "Name": "OZONE", - "PPM": 42, - "Description": ( - "Ozone (O3) is a odorless, colorless ..." - ), - }, - ], - "Period": "0001-01-01T00:00:00", - "Type": "Today", - "Index": 4.5, - "Idx": "4.5", - }, - { - "Triggers": [], - "Period": "0001-01-01T00:00:00", - "Type": "Tomorrow", - "Index": 4.6, - "Idx": "4.6", - }, - ], - "DisplayLocation": REDACTED, - }, - }, - "disease_average_forecasted": { - "Type": "cold", - "ForecastDate": "2018-06-12T00:00:00-04:00", - "Location": { - "ZIP": REDACTED, - "City": REDACTED, - "State": REDACTED, - "periods": [ - {"Period": "2018-06-12T05:13:51.817", "Index": 2.4}, - {"Period": "2018-06-13T05:13:51.817", "Index": 2.5}, - {"Period": "2018-06-14T05:13:51.817", "Index": 2.5}, - {"Period": "2018-06-15T05:13:51.817", "Index": 2.5}, - ], - "DisplayLocation": REDACTED, - }, - }, - "disease_index": { - "ForecastDate": "2019-04-07T00:00:00-04:00", - "Location": { - "City": REDACTED, - "DisplayLocation": REDACTED, - "State": REDACTED, - "ZIP": REDACTED, - "periods": [ - { - "Idx": "6.8", - "Index": 6.8, - "Period": "2019-04-06T00:00:00", - "Triggers": [ - { - "Description": "Influenza", - "Idx": "3.1", - "Index": 3.1, - "Name": "Flu", - }, - { - "Description": "High Fever", - "Idx": "6.2", - "Index": 6.2, - "Name": "Fever", - }, - { - "Description": "Strep & Sore throat", - "Idx": "5.2", - "Index": 5.2, - "Name": "Strep", - }, - { - "Description": "Cough", - "Idx": "7.8", - "Index": 7.8, - "Name": "Cough", - }, - ], - "Type": "Yesterday", - }, - { - "Idx": "6.7", - "Index": 6.7, - "Period": "2019-04-07T03:52:58", - "Triggers": [ - { - "Description": "Influenza", - "Idx": "3.1", - "Index": 3.1, - "Name": "Flu", - }, - { - "Description": "High Fever", - "Idx": "5.9", - "Index": 5.9, - "Name": "Fever", - }, - { - "Description": "Strep & Sore throat", - "Idx": "5.1", - "Index": 5.1, - "Name": "Strep", - }, - { - "Description": "Cough", - "Idx": "7.7", - "Index": 7.7, - "Name": "Cough", - }, - ], - "Type": "Today", - }, - ], - }, - "Type": "cold", - }, - }, - } + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c992628f034 --- /dev/null +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -0,0 +1,1788 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'client_device_id': 'entry-id', + 'password': '**REDACTED**', + 'url': 'https://example.com', + 'username': 'test-username', + }), + 'title': 'Jellyfin', + }), + 'server': dict({ + 'id': 'SERVER-UUID', + 'name': 'JELLYFIN-SERVER', + 'version': None, + }), + 'sessions': list([ + dict({ + 'capabilities': dict({ + 'AppStoreUrl': 'string', + 'DeviceProfile': dict({ + 'AlbumArtPn': 'string', + 'CodecProfiles': list([ + dict({ + 'ApplyConditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Codec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Video', + }), + ]), + 'ContainerProfiles': list([ + dict({ + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Audio', + }), + ]), + 'DirectPlayProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Container': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'EnableAlbumArtInDidl': False, + 'EnableMSMediaReceiverRegistrar': False, + 'EnableSingleAlbumArtLimit': False, + 'EnableSingleSubtitleLimit': False, + 'FriendlyName': 'string', + 'Id': 'string', + 'Identification': dict({ + 'FriendlyName': 'string', + 'Headers': list([ + dict({ + 'Match': 'Equals', + 'Name': 'string', + 'Value': 'string', + }), + ]), + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'SerialNumber': 'string', + }), + 'IgnoreTranscodeByteRangeRequests': False, + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'MaxAlbumArtHeight': 0, + 'MaxAlbumArtWidth': 0, + 'MaxIconHeight': 0, + 'MaxIconWidth': 0, + 'MaxStaticBitrate': 0, + 'MaxStaticMusicBitrate': 0, + 'MaxStreamingBitrate': 0, + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'MusicStreamingTranscodingBitrate': 0, + 'Name': 'string', + 'ProtocolInfo': 'string', + 'RequiresPlainFolders': False, + 'RequiresPlainVideoItems': False, + 'ResponseProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'MimeType': 'string', + 'OrgPn': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'SerialNumber': 'string', + 'SonyAggregationFlags': 'string', + 'SubtitleProfiles': list([ + dict({ + 'Container': 'string', + 'DidlMode': 'string', + 'Format': 'string', + 'Language': 'string', + 'Method': 'Encode', + }), + ]), + 'SupportedMediaTypes': 'string', + 'TimelineOffsetSeconds': 0, + 'TranscodingProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'BreakOnNonKeyFrames': False, + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Context': 'Streaming', + 'CopyTimestamps': False, + 'EnableMpegtsM2TsMode': False, + 'EnableSubtitlesInManifest': False, + 'EstimateContentLength': False, + 'MaxAudioChannels': 'string', + 'MinSegments': 0, + 'Protocol': 'string', + 'SegmentLength': 0, + 'TranscodeSeekInfo': 'Auto', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'UserId': 'string', + 'XmlRootAttributes': list([ + dict({ + 'Name': 'string', + 'Value': 'string', + }), + ]), + }), + 'IconUrl': 'string', + 'MessageCallbackUrl': 'string', + 'PlayableMediaTypes': list([ + 'Video', + ]), + 'SupportedCommands': list([ + 'VolumeSet', + 'Mute', + ]), + 'SupportsContentUploading': True, + 'SupportsMediaControl': True, + 'SupportsPersistentIdentifier': True, + 'SupportsSync': True, + }), + 'client_name': 'Jellyfin for Developers', + 'client_version': '1.0.0', + 'device_id': 'DEVICE-UUID', + 'device_name': 'JELLYFIN-DEVICE', + 'id': 'SESSION-UUID', + 'now_playing': dict({ + 'AirDays': list([ + 'Sunday', + ]), + 'AirTime': 'string', + 'AirsAfterSeasonNumber': 0, + 'AirsBeforeEpisodeNumber': 0, + 'AirsBeforeSeasonNumber': 0, + 'Album': 'string', + 'AlbumArtist': 'string', + 'AlbumArtists': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'AlbumCount': 0, + 'AlbumId': '21af9851-8e39-43a9-9c47-513d3b9e99fc', + 'AlbumPrimaryImageTag': 'string', + 'Altitude': 0, + 'Aperture': 0, + 'ArtistCount': 0, + 'ArtistItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Artists': list([ + 'string', + ]), + 'AspectRatio': 'string', + 'Audio': 'Mono', + 'BackdropImageTags': list([ + 'string', + ]), + 'CameraMake': 'string', + 'CameraModel': 'string', + 'CanDelete': True, + 'CanDownload': True, + 'ChannelId': '04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff', + 'ChannelName': 'string', + 'ChannelNumber': 'string', + 'ChannelPrimaryImageTag': 'string', + 'ChannelType': 'TV', + 'Chapters': list([ + dict({ + 'ImageDateModified': '2019-08-24T14:15:22Z', + 'ImagePath': 'string', + 'ImageTag': 'string', + 'Name': 'string', + 'StartPositionTicks': 0, + }), + ]), + 'ChildCount': 0, + 'CollectionType': 'string', + 'CommunityRating': 0, + 'CompletionPercentage': 0, + 'Container': 'string', + 'CriticRating': 0, + 'CumulativeRunTimeTicks': 0, + 'CurrentProgram': dict({ + }), + 'CustomRating': 'string', + 'DateCreated': '2019-08-24T14:15:22Z', + 'DateLastMediaAdded': '2019-08-24T14:15:22Z', + 'DisplayOrder': 'string', + 'DisplayPreferencesId': 'string', + 'EnableMediaSourceDisplay': True, + 'EndDate': '2019-08-24T14:15:22Z', + 'EpisodeCount': 0, + 'EpisodeTitle': 'string', + 'Etag': 'string', + 'ExposureTime': 0, + 'ExternalUrls': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'ExtraType': 'string', + 'FocalLength': 0, + 'ForcedSortName': 'string', + 'GenreItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Genres': list([ + 'string', + ]), + 'HasSubtitles': True, + 'Height': 0, + 'Id': 'EPISODE-UUID', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'ImageOrientation': 'TopLeft', + 'ImageTags': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'IndexNumber': 3, + 'IndexNumberEnd': 0, + 'IsFolder': False, + 'IsHD': True, + 'IsKids': True, + 'IsLive': True, + 'IsMovie': True, + 'IsNews': True, + 'IsPlaceHolder': True, + 'IsPremiere': True, + 'IsRepeat': True, + 'IsSeries': True, + 'IsSports': True, + 'IsoSpeedRating': 0, + 'IsoType': 'Dvd', + 'Latitude': 0, + 'LocalTrailerCount': 0, + 'LocationType': 'FileSystem', + 'LockData': True, + 'LockedFields': list([ + 'Cast', + ]), + 'Longitude': 0, + 'MediaSourceCount': 0, + 'MediaSources': list([ + dict({ + 'AnalyzeDurationMs': 0, + 'Bitrate': 0, + 'BufferMs': 0, + 'Container': 'string', + 'DefaultAudioStreamIndex': 0, + 'DefaultSubtitleStreamIndex': 0, + 'ETag': 'string', + 'EncoderPath': 'string', + 'EncoderProtocol': 'File', + 'Formats': list([ + 'string', + ]), + 'GenPtsInput': True, + 'Id': 'string', + 'IgnoreDts': True, + 'IgnoreIndex': True, + 'IsInfiniteStream': True, + 'IsRemote': True, + 'IsoType': 'Dvd', + 'LiveStreamId': 'string', + 'MediaAttachments': list([ + dict({ + 'Codec': 'string', + 'CodecTag': 'string', + 'Comment': 'string', + 'DeliveryUrl': 'string', + 'FileName': 'string', + 'Index': 0, + 'MimeType': 'string', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'Name': 'string', + 'OpenToken': 'string', + 'Path': 'string', + 'Protocol': 'File', + 'ReadAtNativeFramerate': True, + 'RequiredHttpHeaders': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RequiresClosing': True, + 'RequiresLooping': True, + 'RequiresOpening': True, + 'RunTimeTicks': 0, + 'Size': 0, + 'SupportsDirectPlay': True, + 'SupportsDirectStream': True, + 'SupportsProbing': True, + 'SupportsTranscoding': True, + 'Timestamp': 'None', + 'TranscodingContainer': 'string', + 'TranscodingSubProtocol': 'string', + 'TranscodingUrl': 'string', + 'Type': 'Default', + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'MediaType': 'string', + 'MovieCount': 0, + 'MusicVideoCount': 0, + 'Name': 'EPISODE', + 'Number': 'string', + 'OfficialRating': 'string', + 'OriginalTitle': 'string', + 'Overview': 'string', + 'ParentArtImageTag': 'string', + 'ParentArtItemId': '10c1875b-b82c-48e8-bae9-939a5e68dc2f', + 'ParentBackdropImageTags': list([ + 'string', + ]), + 'ParentBackdropItemId': 'c22fd826-17fc-44f4-9b04-1eb3e8fb9173', + 'ParentId': 'PARENT-UUID', + 'ParentIndexNumber': 1, + 'ParentLogoImageTag': 'string', + 'ParentLogoItemId': 'c78d400f-de5c-421e-8714-4fb05d387233', + 'ParentPrimaryImageItemId': 'string', + 'ParentPrimaryImageTag': 'string', + 'ParentThumbImageTag': 'string', + 'ParentThumbItemId': 'ae6ff707-333d-4994-be6d-b83ca1b35f46', + 'PartCount': 0, + 'Path': 'string', + 'People': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'Name': 'string', + 'PrimaryImageTag': 'string', + 'Role': 'string', + 'Type': 'string', + }), + ]), + 'PlayAccess': 'Full', + 'PlaylistItemId': 'string', + 'PreferredMetadataCountryCode': 'string', + 'PreferredMetadataLanguage': 'string', + 'PremiereDate': '2019-08-24T14:15:22Z', + 'PrimaryImageAspectRatio': 0, + 'ProductionLocations': list([ + 'string', + ]), + 'ProductionYear': 0, + 'ProgramCount': 0, + 'ProgramId': 'string', + 'ProviderIds': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RecursiveItemCount': 0, + 'RemoteTrailers': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'RunTimeTicks': 600000000, + 'ScreenshotImageTags': list([ + 'string', + ]), + 'SeasonId': 'SEASON-UUID', + 'SeasonName': 'SEASON', + 'SeriesCount': 0, + 'SeriesId': 'SERIES-UUID', + 'SeriesName': 'SERIES', + 'SeriesPrimaryImageTag': 'string', + 'SeriesStudio': 'HASS', + 'SeriesThumbImageTag': 'string', + 'SeriesTimerId': 'string', + 'ServerId': 'SERVER-UUID', + 'ShutterSpeed': 0, + 'Software': 'string', + 'SongCount': 0, + 'SortName': 'string', + 'SourceType': 'string', + 'SpecialFeatureCount': 0, + 'StartDate': '2019-08-24T14:15:22Z', + 'Status': 'string', + 'Studios': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'SupportsSync': True, + 'Taglines': list([ + 'string', + ]), + 'Tags': list([ + 'string', + ]), + 'TimerId': 'string', + 'TrailerCount': 0, + 'Type': 'Episode', + 'UserData': dict({ + 'IsFavorite': True, + 'ItemId': 'string', + 'Key': 'string', + 'LastPlayedDate': '2019-08-24T14:15:22Z', + 'Likes': True, + 'PlayCount': 0, + 'PlaybackPositionTicks': 0, + 'Played': True, + 'PlayedPercentage': 0, + 'Rating': 0, + 'UnplayedItemCount': 0, + }), + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + 'Width': 0, + }), + 'play_state': dict({ + 'AudioStreamIndex': 0, + 'CanSeek': True, + 'IsMuted': True, + 'IsPaused': True, + 'LiveStreamId': 'string', + 'MediaSourceId': 'string', + 'PlayMethod': 'Transcode', + 'PositionTicks': 100000000, + 'RepeatMode': 'RepeatNone', + 'SubtitleStreamIndex': 0, + 'VolumeLevel': 0, + }), + 'user_id': '08ba1929-681e-4b24-929b-9245852f65c0', + }), + dict({ + 'capabilities': dict({ + 'AppStoreUrl': 'string', + 'DeviceProfile': dict({ + 'AlbumArtPn': 'string', + 'CodecProfiles': list([ + dict({ + 'ApplyConditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Codec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Video', + }), + ]), + 'ContainerProfiles': list([ + dict({ + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Audio', + }), + ]), + 'DirectPlayProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Container': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'EnableAlbumArtInDidl': False, + 'EnableMSMediaReceiverRegistrar': False, + 'EnableSingleAlbumArtLimit': False, + 'EnableSingleSubtitleLimit': False, + 'FriendlyName': 'string', + 'Id': 'string', + 'Identification': dict({ + 'FriendlyName': 'string', + 'Headers': list([ + dict({ + 'Match': 'Equals', + 'Name': 'string', + 'Value': 'string', + }), + ]), + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'SerialNumber': 'string', + }), + 'IgnoreTranscodeByteRangeRequests': False, + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'MaxAlbumArtHeight': 0, + 'MaxAlbumArtWidth': 0, + 'MaxIconHeight': 0, + 'MaxIconWidth': 0, + 'MaxStaticBitrate': 0, + 'MaxStaticMusicBitrate': 0, + 'MaxStreamingBitrate': 0, + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'MusicStreamingTranscodingBitrate': 0, + 'Name': 'string', + 'ProtocolInfo': 'string', + 'RequiresPlainFolders': False, + 'RequiresPlainVideoItems': False, + 'ResponseProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'MimeType': 'string', + 'OrgPn': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'SerialNumber': 'string', + 'SonyAggregationFlags': 'string', + 'SubtitleProfiles': list([ + dict({ + 'Container': 'string', + 'DidlMode': 'string', + 'Format': 'string', + 'Language': 'string', + 'Method': 'Encode', + }), + ]), + 'SupportedMediaTypes': 'string', + 'TimelineOffsetSeconds': 0, + 'TranscodingProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'BreakOnNonKeyFrames': False, + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Context': 'Streaming', + 'CopyTimestamps': False, + 'EnableMpegtsM2TsMode': False, + 'EnableSubtitlesInManifest': False, + 'EstimateContentLength': False, + 'MaxAudioChannels': 'string', + 'MinSegments': 0, + 'Protocol': 'string', + 'SegmentLength': 0, + 'TranscodeSeekInfo': 'Auto', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'UserId': 'string', + 'XmlRootAttributes': list([ + dict({ + 'Name': 'string', + 'Value': 'string', + }), + ]), + }), + 'IconUrl': 'string', + 'MessageCallbackUrl': 'string', + 'PlayableMediaTypes': list([ + 'Video', + ]), + 'SupportedCommands': list([ + 'VolumeSet', + 'Mute', + ]), + 'SupportsContentUploading': True, + 'SupportsMediaControl': True, + 'SupportsPersistentIdentifier': True, + 'SupportsSync': True, + }), + 'client_name': 'Jellyfin for Developers', + 'client_version': '1.0.0', + 'device_id': 'DEVICE-UUID-TWO', + 'device_name': 'JELLYFIN-DEVICE-TWO', + 'id': 'SESSION-UUID-TWO', + 'now_playing': dict({ + 'AirDays': list([ + 'Sunday', + ]), + 'AirTime': 'string', + 'AirsAfterSeasonNumber': 0, + 'AirsBeforeEpisodeNumber': 0, + 'AirsBeforeSeasonNumber': 0, + 'Album': 'string', + 'AlbumArtist': 'string', + 'AlbumArtists': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'AlbumCount': 0, + 'AlbumId': '21af9851-8e39-43a9-9c47-513d3b9e99fc', + 'AlbumPrimaryImageTag': 'string', + 'Altitude': 0, + 'Aperture': 0, + 'ArtistCount': 0, + 'ArtistItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Artists': list([ + 'string', + ]), + 'AspectRatio': 'string', + 'Audio': 'Mono', + 'BackdropImageTags': list([ + 'string', + ]), + 'CameraMake': 'string', + 'CameraModel': 'string', + 'CanDelete': True, + 'CanDownload': True, + 'ChannelId': '04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff', + 'ChannelName': 'string', + 'ChannelNumber': 'string', + 'ChannelPrimaryImageTag': 'string', + 'ChannelType': 'TV', + 'Chapters': list([ + dict({ + 'ImageDateModified': '2019-08-24T14:15:22Z', + 'ImagePath': 'string', + 'ImageTag': 'string', + 'Name': 'string', + 'StartPositionTicks': 0, + }), + ]), + 'ChildCount': 0, + 'CollectionType': 'string', + 'CommunityRating': 0, + 'CompletionPercentage': 0, + 'Container': 'string', + 'CriticRating': 0, + 'CumulativeRunTimeTicks': 0, + 'CurrentProgram': dict({ + }), + 'CustomRating': 'string', + 'DateCreated': '2019-08-24T14:15:22Z', + 'DateLastMediaAdded': '2019-08-24T14:15:22Z', + 'DisplayOrder': 'string', + 'DisplayPreferencesId': 'string', + 'EnableMediaSourceDisplay': True, + 'EndDate': '2019-08-24T14:15:22Z', + 'EpisodeCount': 0, + 'EpisodeTitle': 'string', + 'Etag': 'string', + 'ExposureTime': 0, + 'ExternalUrls': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'ExtraType': 'string', + 'FocalLength': 0, + 'ForcedSortName': 'string', + 'GenreItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Genres': list([ + 'string', + ]), + 'HasSubtitles': True, + 'Height': 0, + 'Id': 'EPISODE-UUID', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'ImageOrientation': 'TopLeft', + 'ImageTags': dict({ + 'Backdrop': 'string', + 'property2': 'string', + }), + 'IndexNumber': 0, + 'IndexNumberEnd': 0, + 'IsFolder': False, + 'IsHD': True, + 'IsKids': True, + 'IsLive': True, + 'IsMovie': True, + 'IsNews': True, + 'IsPlaceHolder': True, + 'IsPremiere': True, + 'IsRepeat': True, + 'IsSeries': True, + 'IsSports': True, + 'IsoSpeedRating': 0, + 'IsoType': 'Dvd', + 'Latitude': 0, + 'LocalTrailerCount': 0, + 'LocationType': 'FileSystem', + 'LockData': True, + 'LockedFields': list([ + 'Cast', + ]), + 'Longitude': 0, + 'MediaSourceCount': 0, + 'MediaSources': list([ + dict({ + 'AnalyzeDurationMs': 0, + 'Bitrate': 0, + 'BufferMs': 0, + 'Container': 'string', + 'DefaultAudioStreamIndex': 0, + 'DefaultSubtitleStreamIndex': 0, + 'ETag': 'string', + 'EncoderPath': 'string', + 'EncoderProtocol': 'File', + 'Formats': list([ + 'string', + ]), + 'GenPtsInput': True, + 'Id': 'string', + 'IgnoreDts': True, + 'IgnoreIndex': True, + 'IsInfiniteStream': True, + 'IsRemote': True, + 'IsoType': 'Dvd', + 'LiveStreamId': 'string', + 'MediaAttachments': list([ + dict({ + 'Codec': 'string', + 'CodecTag': 'string', + 'Comment': 'string', + 'DeliveryUrl': 'string', + 'FileName': 'string', + 'Index': 0, + 'MimeType': 'string', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'Name': 'string', + 'OpenToken': 'string', + 'Path': 'string', + 'Protocol': 'File', + 'ReadAtNativeFramerate': True, + 'RequiredHttpHeaders': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RequiresClosing': True, + 'RequiresLooping': True, + 'RequiresOpening': True, + 'RunTimeTicks': 0, + 'Size': 0, + 'SupportsDirectPlay': True, + 'SupportsDirectStream': True, + 'SupportsProbing': True, + 'SupportsTranscoding': True, + 'Timestamp': 'None', + 'TranscodingContainer': 'string', + 'TranscodingSubProtocol': 'string', + 'TranscodingUrl': 'string', + 'Type': 'Default', + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'MediaType': 'string', + 'MovieCount': 0, + 'MusicVideoCount': 0, + 'Name': 'MOVIE', + 'Number': 'string', + 'OfficialRating': 'string', + 'OriginalTitle': 'string', + 'Overview': 'string', + 'ParentArtImageTag': 'string', + 'ParentArtItemId': '10c1875b-b82c-48e8-bae9-939a5e68dc2f', + 'ParentBackdropImageTags': list([ + 'string', + ]), + 'ParentBackdropItemId': '', + 'ParentId': '', + 'ParentIndexNumber': 0, + 'ParentLogoImageTag': 'string', + 'ParentLogoItemId': 'c78d400f-de5c-421e-8714-4fb05d387233', + 'ParentPrimaryImageItemId': 'string', + 'ParentPrimaryImageTag': 'string', + 'ParentThumbImageTag': 'string', + 'ParentThumbItemId': 'ae6ff707-333d-4994-be6d-b83ca1b35f46', + 'PartCount': 0, + 'Path': 'string', + 'People': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'Name': 'string', + 'PrimaryImageTag': 'string', + 'Role': 'string', + 'Type': 'string', + }), + ]), + 'PlayAccess': 'Full', + 'PlaylistItemId': 'string', + 'PreferredMetadataCountryCode': 'string', + 'PreferredMetadataLanguage': 'string', + 'PremiereDate': '2019-08-24T14:15:22Z', + 'PrimaryImageAspectRatio': 0, + 'ProductionLocations': list([ + 'string', + ]), + 'ProductionYear': 0, + 'ProgramCount': 0, + 'ProgramId': 'string', + 'ProviderIds': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RecursiveItemCount': 0, + 'RemoteTrailers': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'RunTimeTicks': 2000000000, + 'ScreenshotImageTags': list([ + 'string', + ]), + 'SeasonId': 'SEASON-UUID', + 'SeasonName': 'SEASON', + 'SeriesCount': 0, + 'SeriesId': 'SERIES-UUID', + 'SeriesName': 'SERIES', + 'SeriesPrimaryImageTag': 'string', + 'SeriesStudio': 'HASS', + 'SeriesThumbImageTag': 'string', + 'SeriesTimerId': 'string', + 'ServerId': 'SERVER-UUID', + 'ShutterSpeed': 0, + 'Software': 'string', + 'SongCount': 0, + 'SortName': 'string', + 'SourceType': 'string', + 'SpecialFeatureCount': 0, + 'StartDate': '2019-08-24T14:15:22Z', + 'Status': 'string', + 'Studios': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'SupportsSync': True, + 'Taglines': list([ + 'string', + ]), + 'Tags': list([ + 'string', + ]), + 'TimerId': 'string', + 'TrailerCount': 0, + 'Type': 'Movie', + 'UserData': dict({ + 'IsFavorite': True, + 'ItemId': 'string', + 'Key': 'string', + 'LastPlayedDate': '2019-08-24T14:15:22Z', + 'Likes': True, + 'PlayCount': 0, + 'PlaybackPositionTicks': 0, + 'Played': True, + 'PlayedPercentage': 0, + 'Rating': 0, + 'UnplayedItemCount': 0, + }), + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + 'Width': 0, + }), + 'play_state': dict({ + 'AudioStreamIndex': 0, + 'CanSeek': True, + 'IsMuted': False, + 'IsPaused': False, + 'LiveStreamId': 'string', + 'MediaSourceId': 'string', + 'PlayMethod': 'Transcode', + 'PositionTicks': 230000000, + 'RepeatMode': 'RepeatNone', + 'SubtitleStreamIndex': 0, + 'VolumeLevel': 55, + }), + 'user_id': 'USER-UUID-TWO', + }), + dict({ + 'capabilities': dict({ + 'AppStoreUrl': 'string', + 'DeviceProfile': dict({ + 'AlbumArtPn': 'string', + 'CodecProfiles': list([ + dict({ + 'ApplyConditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Codec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Video', + }), + ]), + 'ContainerProfiles': list([ + dict({ + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Audio', + }), + ]), + 'DirectPlayProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Container': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'EnableAlbumArtInDidl': False, + 'EnableMSMediaReceiverRegistrar': False, + 'EnableSingleAlbumArtLimit': False, + 'EnableSingleSubtitleLimit': False, + 'FriendlyName': 'string', + 'Id': 'string', + 'Identification': dict({ + 'FriendlyName': 'string', + 'Headers': list([ + dict({ + 'Match': 'Equals', + 'Name': 'string', + 'Value': 'string', + }), + ]), + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'SerialNumber': 'string', + }), + 'IgnoreTranscodeByteRangeRequests': False, + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'MaxAlbumArtHeight': 0, + 'MaxAlbumArtWidth': 0, + 'MaxIconHeight': 0, + 'MaxIconWidth': 0, + 'MaxStaticBitrate': 0, + 'MaxStaticMusicBitrate': 0, + 'MaxStreamingBitrate': 0, + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'MusicStreamingTranscodingBitrate': 0, + 'Name': 'string', + 'ProtocolInfo': 'string', + 'RequiresPlainFolders': False, + 'RequiresPlainVideoItems': False, + 'ResponseProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'MimeType': 'string', + 'OrgPn': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'SerialNumber': 'string', + 'SonyAggregationFlags': 'string', + 'SubtitleProfiles': list([ + dict({ + 'Container': 'string', + 'DidlMode': 'string', + 'Format': 'string', + 'Language': 'string', + 'Method': 'Encode', + }), + ]), + 'SupportedMediaTypes': 'string', + 'TimelineOffsetSeconds': 0, + 'TranscodingProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'BreakOnNonKeyFrames': False, + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Context': 'Streaming', + 'CopyTimestamps': False, + 'EnableMpegtsM2TsMode': False, + 'EnableSubtitlesInManifest': False, + 'EstimateContentLength': False, + 'MaxAudioChannels': 'string', + 'MinSegments': 0, + 'Protocol': 'string', + 'SegmentLength': 0, + 'TranscodeSeekInfo': 'Auto', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'UserId': 'string', + 'XmlRootAttributes': list([ + dict({ + 'Name': 'string', + 'Value': 'string', + }), + ]), + }), + 'IconUrl': 'string', + 'MessageCallbackUrl': 'string', + 'PlayableMediaTypes': list([ + 'Video', + ]), + 'SupportedCommands': list([ + 'MoveUp', + ]), + 'SupportsContentUploading': False, + 'SupportsMediaControl': False, + 'SupportsPersistentIdentifier': False, + 'SupportsSync': True, + }), + 'client_name': 'Jellyfin for Developers', + 'client_version': '2.0.0', + 'device_id': 'DEVICE-UUID-THREE', + 'device_name': 'JELLYFIN-DEVICE-THREE', + 'id': 'SESSION-UUID-THREE', + 'now_playing': None, + 'play_state': dict({ + 'AudioStreamIndex': 0, + 'CanSeek': True, + 'IsMuted': True, + 'IsPaused': False, + 'LiveStreamId': 'string', + 'MediaSourceId': 'string', + 'PlayMethod': 'Transcode', + 'PositionTicks': 0, + 'RepeatMode': 'RepeatNone', + 'SubtitleStreamIndex': 0, + 'VolumeLevel': 0, + }), + 'user_id': 'USER-UUID', + }), + dict({ + 'capabilities': dict({ + 'PlayableMediaTypes': list([ + 'Audio', + 'Video', + ]), + 'SupportedCommands': list([ + 'MoveUp', + 'MoveDown', + 'MoveLeft', + 'MoveRight', + 'PageUp', + 'PageDown', + 'PreviousLetter', + 'NextLetter', + 'ToggleOsd', + 'ToggleContextMenu', + 'Select', + 'Back', + 'SendKey', + 'SendString', + 'GoHome', + 'GoToSettings', + 'VolumeUp', + 'VolumeDown', + 'Mute', + 'Unmute', + 'ToggleMute', + 'SetVolume', + 'SetAudioStreamIndex', + 'SetSubtitleStreamIndex', + 'DisplayContent', + 'GoToSearch', + 'DisplayMessage', + 'SetRepeatMode', + 'SetShuffleQueue', + 'ChannelUp', + 'ChannelDown', + 'PlayMediaSource', + 'PlayTrailers', + ]), + 'SupportsContentUploading': False, + 'SupportsMediaControl': True, + 'SupportsPersistentIdentifier': False, + 'SupportsSync': False, + }), + 'client_name': 'Jellyfin Android', + 'client_version': '2.4.4', + 'device_id': 'DEVICE-UUID-FOUR', + 'device_name': 'JELLYFIN DEVICE FOUR', + 'id': 'SESSION-UUID-FOUR', + 'now_playing': dict({ + 'Album': 'ALBUM', + 'AlbumArtist': 'Album Artist', + 'AlbumArtists': list([ + dict({ + 'Id': '9a65b2c222ddb34e51f5cae360fad3a1', + 'Name': 'Album Artist', + }), + ]), + 'AlbumId': 'ALBUM-UUID', + 'ArtistItems': list([ + dict({ + 'Id': '1d864900526d9a9513b489f1cc28f8ca', + 'Name': 'Contributing Artist', + }), + ]), + 'Artists': list([ + 'Contributing Artist', + ]), + 'BackdropImageTags': list([ + ]), + 'ChannelId': None, + 'DateCreated': '2022-10-19T03:09:11.392057Z', + 'EnableMediaSourceDisplay': True, + 'ExternalUrls': list([ + ]), + 'GenreItems': list([ + ]), + 'Genres': list([ + ]), + 'Id': 'MUSIC-UUID', + 'ImageBlurHashes': dict({ + }), + 'ImageTags': dict({ + }), + 'IndexNumber': 1, + 'IsFolder': False, + 'LocalTrailerCount': 0, + 'LocationType': 'FileSystem', + 'MediaStreams': list([ + dict({ + 'BitRate': 256000, + 'ChannelLayout': 'stereo', + 'Channels': 2, + 'Codec': 'mp3', + 'DisplayTitle': 'MP3 - Stereo', + 'Index': 0, + 'IsDefault': False, + 'IsExternal': False, + 'IsForced': False, + 'IsInterlaced': False, + 'IsTextSubtitleStream': False, + 'Level': 0, + 'SampleRate': 44100, + 'SupportsExternalStream': False, + 'TimeBase': '1/14112000', + 'Type': 'Audio', + }), + ]), + 'MediaType': 'Audio', + 'Name': 'MUSIC FILE', + 'ParentId': '4c0343ed1bbcda094178076230051b7e', + 'Path': 'string', + 'ProviderIds': dict({ + }), + 'RunTimeTicks': 736391552, + 'ServerId': 'SERVER-UUID', + 'SpecialFeatureCount': 0, + 'Studios': list([ + ]), + 'Taglines': list([ + ]), + 'Type': 'Audio', + }), + 'play_state': dict({ + 'CanSeek': True, + 'IsMuted': False, + 'IsPaused': False, + 'MediaSourceId': 'a744119f757f88858f95aab1628708c4', + 'PlayMethod': 'DirectPlay', + 'PositionTicks': 220246970, + 'RepeatMode': 'RepeatNone', + 'VolumeLevel': 100, + }), + 'user_id': 'USER-UUID-TWO', + }), + ]), + }) +# --- diff --git a/tests/components/jellyfin/test_diagnostics.py b/tests/components/jellyfin/test_diagnostics.py index 15561f5294c..b56d864eaac 100644 --- a/tests/components/jellyfin/test_diagnostics.py +++ b/tests/components/jellyfin/test_diagnostics.py @@ -1,4 +1,5 @@ """Test Jellyfin diagnostics.""" +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,601 +12,12 @@ async def test_diagnostics( hass: HomeAssistant, init_integration: MockConfigEntry, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" - entry = init_integration + data = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag - assert diag["entry"] == { - "title": "Jellyfin", - "data": { - "url": "https://example.com", - "username": "test-username", - "password": "**REDACTED**", - "client_device_id": entry.entry_id, - }, - } - assert diag["server"] == { - "id": "SERVER-UUID", - "name": "JELLYFIN-SERVER", - "version": None, - } - assert diag["sessions"] - assert len(diag["sessions"]) == 4 - assert diag["sessions"][0] == { - "id": "SESSION-UUID", - "user_id": "08ba1929-681e-4b24-929b-9245852f65c0", - "device_id": "DEVICE-UUID", - "device_name": "JELLYFIN-DEVICE", - "client_name": "Jellyfin for Developers", - "client_version": "1.0.0", - "capabilities": { - "PlayableMediaTypes": ["Video"], - "SupportedCommands": ["VolumeSet", "Mute"], - "SupportsMediaControl": True, - "SupportsContentUploading": True, - "MessageCallbackUrl": "string", - "SupportsPersistentIdentifier": True, - "SupportsSync": True, - "DeviceProfile": { - "Name": "string", - "Id": "string", - "Identification": { - "FriendlyName": "string", - "ModelNumber": "string", - "SerialNumber": "string", - "ModelName": "string", - "ModelDescription": "string", - "ModelUrl": "string", - "Manufacturer": "string", - "ManufacturerUrl": "string", - "Headers": [ - {"Name": "string", "Value": "string", "Match": "Equals"} - ], - }, - "FriendlyName": "string", - "Manufacturer": "string", - "ManufacturerUrl": "string", - "ModelName": "string", - "ModelDescription": "string", - "ModelNumber": "string", - "ModelUrl": "string", - "SerialNumber": "string", - "EnableAlbumArtInDidl": False, - "EnableSingleAlbumArtLimit": False, - "EnableSingleSubtitleLimit": False, - "SupportedMediaTypes": "string", - "UserId": "string", - "AlbumArtPn": "string", - "MaxAlbumArtWidth": 0, - "MaxAlbumArtHeight": 0, - "MaxIconWidth": 0, - "MaxIconHeight": 0, - "MaxStreamingBitrate": 0, - "MaxStaticBitrate": 0, - "MusicStreamingTranscodingBitrate": 0, - "MaxStaticMusicBitrate": 0, - "SonyAggregationFlags": "string", - "ProtocolInfo": "string", - "TimelineOffsetSeconds": 0, - "RequiresPlainVideoItems": False, - "RequiresPlainFolders": False, - "EnableMSMediaReceiverRegistrar": False, - "IgnoreTranscodeByteRangeRequests": False, - "XmlRootAttributes": [{"Name": "string", "Value": "string"}], - "DirectPlayProfiles": [ - { - "Container": "string", - "AudioCodec": "string", - "VideoCodec": "string", - "Type": "Audio", - } - ], - "TranscodingProfiles": [ - { - "Container": "string", - "Type": "Audio", - "VideoCodec": "string", - "AudioCodec": "string", - "Protocol": "string", - "EstimateContentLength": False, - "EnableMpegtsM2TsMode": False, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": False, - "Context": "Streaming", - "EnableSubtitlesInManifest": False, - "MaxAudioChannels": "string", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": False, - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - } - ], - "ContainerProfiles": [ - { - "Type": "Audio", - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - "Container": "string", - } - ], - "CodecProfiles": [ - { - "Type": "Video", - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - "ApplyConditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - "Codec": "string", - "Container": "string", - } - ], - "ResponseProfiles": [ - { - "Container": "string", - "AudioCodec": "string", - "VideoCodec": "string", - "Type": "Audio", - "OrgPn": "string", - "MimeType": "string", - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - } - ], - "SubtitleProfiles": [ - { - "Format": "string", - "Method": "Encode", - "DidlMode": "string", - "Language": "string", - "Container": "string", - } - ], - }, - "AppStoreUrl": "string", - "IconUrl": "string", - }, - "now_playing": { - "Name": "EPISODE", - "OriginalTitle": "string", - "ServerId": "SERVER-UUID", - "Id": "EPISODE-UUID", - "Etag": "string", - "SourceType": "string", - "PlaylistItemId": "string", - "DateCreated": "2019-08-24T14:15:22Z", - "DateLastMediaAdded": "2019-08-24T14:15:22Z", - "ExtraType": "string", - "AirsBeforeSeasonNumber": 0, - "AirsAfterSeasonNumber": 0, - "AirsBeforeEpisodeNumber": 0, - "CanDelete": True, - "CanDownload": True, - "HasSubtitles": True, - "PreferredMetadataLanguage": "string", - "PreferredMetadataCountryCode": "string", - "SupportsSync": True, - "Container": "string", - "SortName": "string", - "ForcedSortName": "string", - "Video3DFormat": "HalfSideBySide", - "PremiereDate": "2019-08-24T14:15:22Z", - "ExternalUrls": [{"Name": "string", "Url": "string"}], - "MediaSources": [ - { - "Protocol": "File", - "Id": "string", - "Path": "string", - "EncoderPath": "string", - "EncoderProtocol": "File", - "Type": "Default", - "Container": "string", - "Size": 0, - "Name": "string", - "IsRemote": True, - "ETag": "string", - "RunTimeTicks": 0, - "ReadAtNativeFramerate": True, - "IgnoreDts": True, - "IgnoreIndex": True, - "GenPtsInput": True, - "SupportsTranscoding": True, - "SupportsDirectStream": True, - "SupportsDirectPlay": True, - "IsInfiniteStream": True, - "RequiresOpening": True, - "OpenToken": "string", - "RequiresClosing": True, - "LiveStreamId": "string", - "BufferMs": 0, - "RequiresLooping": True, - "SupportsProbing": True, - "VideoType": "VideoFile", - "IsoType": "Dvd", - "Video3DFormat": "HalfSideBySide", - "MediaStreams": [ - { - "Codec": "string", - "CodecTag": "string", - "Language": "string", - "ColorRange": "string", - "ColorSpace": "string", - "ColorTransfer": "string", - "ColorPrimaries": "string", - "DvVersionMajor": 0, - "DvVersionMinor": 0, - "DvProfile": 0, - "DvLevel": 0, - "RpuPresentFlag": 0, - "ElPresentFlag": 0, - "BlPresentFlag": 0, - "DvBlSignalCompatibilityId": 0, - "Comment": "string", - "TimeBase": "string", - "CodecTimeBase": "string", - "Title": "string", - "VideoRange": "string", - "VideoRangeType": "string", - "VideoDoViTitle": "string", - "LocalizedUndefined": "string", - "LocalizedDefault": "string", - "LocalizedForced": "string", - "LocalizedExternal": "string", - "DisplayTitle": "string", - "NalLengthSize": "string", - "IsInterlaced": True, - "IsAVC": True, - "ChannelLayout": "string", - "BitRate": 0, - "BitDepth": 0, - "RefFrames": 0, - "PacketLength": 0, - "Channels": 0, - "SampleRate": 0, - "IsDefault": True, - "IsForced": True, - "Height": 0, - "Width": 0, - "AverageFrameRate": 0, - "RealFrameRate": 0, - "Profile": "string", - "Type": "Audio", - "AspectRatio": "string", - "Index": 0, - "Score": 0, - "IsExternal": True, - "DeliveryMethod": "Encode", - "DeliveryUrl": "string", - "IsExternalUrl": True, - "IsTextSubtitleStream": True, - "SupportsExternalStream": True, - "Path": "string", - "PixelFormat": "string", - "Level": 0, - "IsAnamorphic": True, - } - ], - "MediaAttachments": [ - { - "Codec": "string", - "CodecTag": "string", - "Comment": "string", - "Index": 0, - "FileName": "string", - "MimeType": "string", - "DeliveryUrl": "string", - } - ], - "Formats": ["string"], - "Bitrate": 0, - "Timestamp": "None", - "RequiredHttpHeaders": { - "property1": "string", - "property2": "string", - }, - "TranscodingUrl": "string", - "TranscodingSubProtocol": "string", - "TranscodingContainer": "string", - "AnalyzeDurationMs": 0, - "DefaultAudioStreamIndex": 0, - "DefaultSubtitleStreamIndex": 0, - } - ], - "CriticRating": 0, - "ProductionLocations": ["string"], - "Path": "string", - "EnableMediaSourceDisplay": True, - "OfficialRating": "string", - "CustomRating": "string", - "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", - "ChannelName": "string", - "Overview": "string", - "Taglines": ["string"], - "Genres": ["string"], - "CommunityRating": 0, - "CumulativeRunTimeTicks": 0, - "RunTimeTicks": 600000000, - "PlayAccess": "Full", - "AspectRatio": "string", - "ProductionYear": 0, - "IsPlaceHolder": True, - "Number": "string", - "ChannelNumber": "string", - "IndexNumber": 3, - "IndexNumberEnd": 0, - "ParentIndexNumber": 1, - "RemoteTrailers": [{"Url": "string", "Name": "string"}], - "ProviderIds": {"property1": "string", "property2": "string"}, - "IsHD": True, - "IsFolder": False, - "ParentId": "PARENT-UUID", - "Type": "Episode", - "People": [ - { - "Name": "string", - "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", - "Role": "string", - "Type": "string", - "PrimaryImageTag": "string", - "ImageBlurHashes": { - "Primary": {"property1": "string", "property2": "string"}, - "Art": {"property1": "string", "property2": "string"}, - "Backdrop": {"property1": "string", "property2": "string"}, - "Banner": {"property1": "string", "property2": "string"}, - "Logo": {"property1": "string", "property2": "string"}, - "Thumb": {"property1": "string", "property2": "string"}, - "Disc": {"property1": "string", "property2": "string"}, - "Box": {"property1": "string", "property2": "string"}, - "Screenshot": {"property1": "string", "property2": "string"}, - "Menu": {"property1": "string", "property2": "string"}, - "Chapter": {"property1": "string", "property2": "string"}, - "BoxRear": {"property1": "string", "property2": "string"}, - "Profile": {"property1": "string", "property2": "string"}, - }, - } - ], - "Studios": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "GenreItems": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", - "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", - "ParentBackdropImageTags": ["string"], - "LocalTrailerCount": 0, - "UserData": { - "Rating": 0, - "PlayedPercentage": 0, - "UnplayedItemCount": 0, - "PlaybackPositionTicks": 0, - "PlayCount": 0, - "IsFavorite": True, - "Likes": True, - "LastPlayedDate": "2019-08-24T14:15:22Z", - "Played": True, - "Key": "string", - "ItemId": "string", - }, - "RecursiveItemCount": 0, - "ChildCount": 0, - "SeriesName": "SERIES", - "SeriesId": "SERIES-UUID", - "SeasonId": "SEASON-UUID", - "SpecialFeatureCount": 0, - "DisplayPreferencesId": "string", - "Status": "string", - "AirTime": "string", - "AirDays": ["Sunday"], - "Tags": ["string"], - "PrimaryImageAspectRatio": 0, - "Artists": ["string"], - "ArtistItems": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "Album": "string", - "CollectionType": "string", - "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", - "SeriesPrimaryImageTag": "string", - "AlbumArtist": "string", - "AlbumArtists": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "SeasonName": "SEASON", - "MediaStreams": [ - { - "Codec": "string", - "CodecTag": "string", - "Language": "string", - "ColorRange": "string", - "ColorSpace": "string", - "ColorTransfer": "string", - "ColorPrimaries": "string", - "DvVersionMajor": 0, - "DvVersionMinor": 0, - "DvProfile": 0, - "DvLevel": 0, - "RpuPresentFlag": 0, - "ElPresentFlag": 0, - "BlPresentFlag": 0, - "DvBlSignalCompatibilityId": 0, - "Comment": "string", - "TimeBase": "string", - "CodecTimeBase": "string", - "Title": "string", - "VideoRange": "string", - "VideoRangeType": "string", - "VideoDoViTitle": "string", - "LocalizedUndefined": "string", - "LocalizedDefault": "string", - "LocalizedForced": "string", - "LocalizedExternal": "string", - "DisplayTitle": "string", - "NalLengthSize": "string", - "IsInterlaced": True, - "IsAVC": True, - "ChannelLayout": "string", - "BitRate": 0, - "BitDepth": 0, - "RefFrames": 0, - "PacketLength": 0, - "Channels": 0, - "SampleRate": 0, - "IsDefault": True, - "IsForced": True, - "Height": 0, - "Width": 0, - "AverageFrameRate": 0, - "RealFrameRate": 0, - "Profile": "string", - "Type": "Audio", - "AspectRatio": "string", - "Index": 0, - "Score": 0, - "IsExternal": True, - "DeliveryMethod": "Encode", - "DeliveryUrl": "string", - "IsExternalUrl": True, - "IsTextSubtitleStream": True, - "SupportsExternalStream": True, - "Path": "string", - "PixelFormat": "string", - "Level": 0, - "IsAnamorphic": True, - } - ], - "VideoType": "VideoFile", - "PartCount": 0, - "MediaSourceCount": 0, - "ImageTags": {"property1": "string", "property2": "string"}, - "BackdropImageTags": ["string"], - "ScreenshotImageTags": ["string"], - "ParentLogoImageTag": "string", - "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", - "ParentArtImageTag": "string", - "SeriesThumbImageTag": "string", - "ImageBlurHashes": { - "Primary": {"property1": "string", "property2": "string"}, - "Art": {"property1": "string", "property2": "string"}, - "Backdrop": {"property1": "string", "property2": "string"}, - "Banner": {"property1": "string", "property2": "string"}, - "Logo": {"property1": "string", "property2": "string"}, - "Thumb": {"property1": "string", "property2": "string"}, - "Disc": {"property1": "string", "property2": "string"}, - "Box": {"property1": "string", "property2": "string"}, - "Screenshot": {"property1": "string", "property2": "string"}, - "Menu": {"property1": "string", "property2": "string"}, - "Chapter": {"property1": "string", "property2": "string"}, - "BoxRear": {"property1": "string", "property2": "string"}, - "Profile": {"property1": "string", "property2": "string"}, - }, - "SeriesStudio": "HASS", - "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", - "ParentThumbImageTag": "string", - "ParentPrimaryImageItemId": "string", - "ParentPrimaryImageTag": "string", - "Chapters": [ - { - "StartPositionTicks": 0, - "Name": "string", - "ImagePath": "string", - "ImageDateModified": "2019-08-24T14:15:22Z", - "ImageTag": "string", - } - ], - "LocationType": "FileSystem", - "IsoType": "Dvd", - "MediaType": "string", - "EndDate": "2019-08-24T14:15:22Z", - "LockedFields": ["Cast"], - "TrailerCount": 0, - "MovieCount": 0, - "SeriesCount": 0, - "ProgramCount": 0, - "EpisodeCount": 0, - "SongCount": 0, - "AlbumCount": 0, - "ArtistCount": 0, - "MusicVideoCount": 0, - "LockData": True, - "Width": 0, - "Height": 0, - "CameraMake": "string", - "CameraModel": "string", - "Software": "string", - "ExposureTime": 0, - "FocalLength": 0, - "ImageOrientation": "TopLeft", - "Aperture": 0, - "ShutterSpeed": 0, - "Latitude": 0, - "Longitude": 0, - "Altitude": 0, - "IsoSpeedRating": 0, - "SeriesTimerId": "string", - "ProgramId": "string", - "ChannelPrimaryImageTag": "string", - "StartDate": "2019-08-24T14:15:22Z", - "CompletionPercentage": 0, - "IsRepeat": True, - "EpisodeTitle": "string", - "ChannelType": "TV", - "Audio": "Mono", - "IsMovie": True, - "IsSports": True, - "IsSeries": True, - "IsLive": True, - "IsNews": True, - "IsKids": True, - "IsPremiere": True, - "TimerId": "string", - "CurrentProgram": {}, - }, - "play_state": { - "PositionTicks": 100000000, - "CanSeek": True, - "IsPaused": True, - "IsMuted": True, - "VolumeLevel": 0, - "AudioStreamIndex": 0, - "SubtitleStreamIndex": 0, - "MediaSourceId": "string", - "PlayMethod": "Transcode", - "RepeatMode": "RepeatNone", - "LiveStreamId": "string", - }, - } + assert data["entry"]["data"]["client_device_id"] == init_integration.entry_id + data["entry"]["data"]["client_device_id"] = "entry-id" + + assert data == snapshot diff --git a/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr new file mode 100644 index 00000000000..879e78d5534 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_states + set({ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can do all', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_do_all', + 'last_changed': , + 'last_updated': , + 'state': 'docked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can dock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_dock', + 'last_changed': , + 'last_updated': , + 'state': 'mowing', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can mow', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_mow', + 'last_changed': , + 'last_updated': , + 'state': 'docked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can pause', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_pause', + 'last_changed': , + 'last_updated': , + 'state': 'docked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower is paused', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_is_paused', + 'last_changed': , + 'last_updated': , + 'state': 'paused', + }), + }) +# --- diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 88f2de5b394..ebd0f781d22 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -212,6 +212,7 @@ async def test_issues_created( "flow_id": ANY, "handler": DOMAIN, "last_step": None, + "preview": None, "step_id": "confirm", "type": "form", } diff --git a/tests/components/kitchen_sink/test_lawn_mower.py b/tests/components/kitchen_sink/test_lawn_mower.py new file mode 100644 index 00000000000..efd1b7485ab --- /dev/null +++ b/tests/components/kitchen_sink/test_lawn_mower.py @@ -0,0 +1,116 @@ +"""The tests for the kitchen_sink lawn mower platform.""" +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + LawnMowerActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events, async_mock_service + +MOWER_SERVICE_ENTITY = "lawn_mower.mower_can_dock" + + +@pytest.fixture +async def lawn_mower_only() -> None: + """Enable only the lawn mower platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.LAWN_MOWER], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, lawn_mower_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test the expected lawn mower entities are added.""" + states = hass.states.async_all() + assert set(states) == snapshot + + +@pytest.mark.parametrize( + ("entity", "service_call", "activity", "next_activity"), + [ + ( + "lawn_mower.mower_can_mow", + SERVICE_START_MOWING, + LawnMowerActivity.DOCKED, + LawnMowerActivity.MOWING, + ), + ( + "lawn_mower.mower_can_pause", + SERVICE_PAUSE, + LawnMowerActivity.DOCKED, + LawnMowerActivity.PAUSED, + ), + ( + "lawn_mower.mower_is_paused", + SERVICE_START_MOWING, + LawnMowerActivity.PAUSED, + LawnMowerActivity.MOWING, + ), + ( + "lawn_mower.mower_can_dock", + SERVICE_DOCK, + LawnMowerActivity.MOWING, + LawnMowerActivity.DOCKED, + ), + ], +) +async def test_mower( + hass: HomeAssistant, + entity: str, + service_call: str, + activity: LawnMowerActivity, + next_activity: LawnMowerActivity, +) -> None: + """Test the activity states of a lawn mower.""" + state = hass.states.get(entity) + + assert state.state == str(activity.value) + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + LAWN_MOWER_DOMAIN, service_call, {ATTR_ENTITY_ID: entity}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == entity + assert state_changes[0].data["new_state"].state == str(next_activity.value) + + +@pytest.mark.parametrize( + "service_call", + [ + SERVICE_DOCK, + SERVICE_START_MOWING, + SERVICE_PAUSE, + ], +) +async def test_service_calls_mocked(hass: HomeAssistant, service_call) -> None: + """Test the services of a lawn mower.""" + calls = async_mock_service(hass, LAWN_MOWER_DOMAIN, service_call) + await hass.services.async_call( + LAWN_MOWER_DOMAIN, + service_call, + {ATTR_ENTITY_ID: MOWER_SERVICE_ENTITY}, + blocking=True, + ) + assert len(calls) == 1 diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr new file mode 100644 index 00000000000..4323dd113cd --- /dev/null +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_diagnostic_config_error[hass_config0] + dict({ + 'config_entry_data': dict({ + 'connection_type': 'automatic', + 'individual_address': '0.0.240', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + }), + 'configuration_error': "extra keys not allowed @ data['knx']['wrong_key']", + 'configuration_yaml': dict({ + 'wrong_key': dict({ + }), + }), + 'project_info': None, + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- +# name: test_diagnostic_redact[hass_config0] + dict({ + 'config_entry_data': dict({ + 'backbone_key': '**REDACTED**', + 'connection_type': 'automatic', + 'device_authentication': '**REDACTED**', + 'individual_address': '0.0.240', + 'knxkeys_password': '**REDACTED**', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + 'user_password': '**REDACTED**', + }), + 'configuration_error': None, + 'configuration_yaml': None, + 'project_info': None, + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- +# name: test_diagnostics[hass_config0] + dict({ + 'config_entry_data': dict({ + 'connection_type': 'automatic', + 'individual_address': '0.0.240', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + }), + 'configuration_error': None, + 'configuration_yaml': None, + 'project_info': None, + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- +# name: test_diagnostics_project[hass_config0] + dict({ + 'config_entry_data': dict({ + 'connection_type': 'automatic', + 'individual_address': '0.0.240', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + }), + 'configuration_error': None, + 'configuration_yaml': None, + 'project_info': dict({ + 'created_by': 'ETS5', + 'group_address_style': 'ThreeLevel', + 'guid': '6a019e80-5945-489e-95a3-378735c642d1', + 'language_code': 'de-DE', + 'last_modified': '2023-04-30T09:04:04.4043671Z', + 'name': '**REDACTED**', + 'project_id': 'P-04FF', + 'schema_version': '20', + 'tool_version': '5.7.1428.39779', + 'xknxproject_version': '3.1.0', + }), + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 5463892a3ef..f8200214019 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -71,9 +71,9 @@ def fixture_knx_setup(): def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): """Patch file upload. Yields the Keyring instance (return_value).""" with patch( - "homeassistant.components.knx.config_flow.process_uploaded_file" + "homeassistant.components.knx.helpers.keyring.process_uploaded_file" ) as file_upload_mock, patch( - "homeassistant.components.knx.config_flow.sync_load_keyring", + "homeassistant.components.knx.helpers.keyring.sync_load_keyring", return_value=return_value, side_effect=side_effect, ), patch( diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index c3d3ed67b03..e901fd7f29e 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -201,6 +201,7 @@ async def test_get_trigger_capabilities_node_status( "mode": "dropdown", "multiple": True, "options": [], + "sort": False, }, }, } diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index df8cb71d4af..0b43433c01e 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -1,6 +1,7 @@ """Tests for the diagnostics data provided by the KNX integration.""" import pytest +from syrupy import SnapshotAssertion from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from homeassistant.components.knx.const import ( @@ -36,28 +37,17 @@ async def test_diagnostics( mock_config_entry: MockConfigEntry, knx: KNXTestKit, mock_hass_config: None, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" await knx.setup_integration({}) # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - }, - "configuration_error": None, - "configuration_yaml": None, - "project_info": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + knx.xknx.version = "0.0.0" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) @pytest.mark.parametrize("hass_config", [{"knx": {"wrong_key": {}}}]) @@ -67,28 +57,18 @@ async def test_diagnostic_config_error( hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, knx: KNXTestKit, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" await knx.setup_integration({}) # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - }, - "configuration_error": "extra keys not allowed @ data['knx']['wrong_key']", - "configuration_yaml": {"wrong_key": {}}, - "project_info": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + knx.xknx.version = "0.0.0" + # the snapshot will contain 'configuration_error' key with the voluptuous error message + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) @pytest.mark.parametrize("hass_config", [{}]) @@ -96,6 +76,7 @@ async def test_diagnostic_redact( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_hass_config: None, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics redacting data.""" mock_config_entry: MockConfigEntry = MockConfigEntry( @@ -118,27 +99,11 @@ async def test_diagnostic_redact( await knx.setup_integration({}) # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - "knxkeys_password": "**REDACTED**", - "user_password": "**REDACTED**", - "device_authentication": "**REDACTED**", - "backbone_key": "**REDACTED**", - }, - "configuration_error": None, - "configuration_yaml": None, - "project_info": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + knx.xknx.version = "0.0.0" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) @pytest.mark.parametrize("hass_config", [{}]) @@ -149,21 +114,13 @@ async def test_diagnostics_project( knx: KNXTestKit, mock_hass_config: None, load_knxproj: None, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" await knx.setup_integration({}) - diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) - - assert "config_entry_data" in diag - assert "configuration_error" in diag - assert "configuration_yaml" in diag - assert "project_info" in diag - assert "xknx" in diag - # project specific fields - assert "created_by" in diag["project_info"] - assert "group_address_style" in diag["project_info"] - assert "last_modified" in diag["project_info"] - assert "schema_version" in diag["project_info"] - assert "tool_version" in diag["project_info"] - assert "language_code" in diag["project_info"] - assert diag["project_info"]["name"] == "**REDACTED**" + knx.xknx.version = "0.0.0" + # snapshot will contain project specific fields in `project_info` + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index f0e7752d7c0..814a46f4a25 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.kostal_plenticore.helper import Plenticore from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from tests.common import MockConfigEntry diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index cc522c96974..61df222fd9e 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components.kostal_plenticore.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from tests.common import MockConfigEntry diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index b8f1f165069..8efac3017e0 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from pykrakenapi.pykrakenapi import KrakenAPIError from homeassistant.components.kraken.const import ( @@ -13,7 +14,6 @@ from homeassistant.components.kraken.const import ( from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START 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 .const import ( MISSING_PAIR_TICKER_INFORMATION_RESPONSE, @@ -25,11 +25,9 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test that sensor has a value.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ), patch( @@ -230,11 +228,11 @@ async def test_sensor(hass: HomeAssistant) -> None: assert xbt_usd_opening_price_today.state == "0.0003513" -async def test_sensors_available_after_restart(hass: HomeAssistant) -> None: +async def test_sensors_available_after_restart( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that all sensors are added again after a restart.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ), patch( @@ -248,6 +246,7 @@ async def test_sensors_available_after_restart(hass: HomeAssistant) -> None: CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR], }, ) + entry.add_to_hass(hass) device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -270,11 +269,11 @@ async def test_sensors_available_after_restart(hass: HomeAssistant) -> None: assert sensor.state == "0.0003494" -async def test_sensors_added_after_config_update(hass: HomeAssistant) -> None: +async def test_sensors_added_after_config_update( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that sensors are added when another tracked asset pair is added.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ), patch( @@ -308,19 +307,18 @@ async def test_sensors_added_after_config_update(hass: HomeAssistant) -> None: CONF_TRACKED_ASSET_PAIRS: [DEFAULT_TRACKED_ASSET_PAIR, "ADA/XBT"], }, ) - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.ada_xbt_ask") -async def test_missing_pair_marks_sensor_unavailable(hass: HomeAssistant) -> None: +async def test_missing_pair_marks_sensor_unavailable( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that a missing tradable asset pair marks the sensor unavailable.""" - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", return_value=TRADEABLE_ASSET_PAIR_RESPONSE, ) as tradeable_asset_pairs_mock, patch( @@ -352,16 +350,14 @@ async def test_missing_pair_marks_sensor_unavailable(hass: HomeAssistant) -> Non ticket_information_mock.side_effect = KrakenAPIError( "EQuery:Unknown asset pair" ) - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2)) + async_fire_time_changed(hass) await hass.async_block_till_done() ticket_information_mock.side_effect = None ticket_information_mock.return_value = MISSING_PAIR_TICKER_INFORMATION_RESPONSE - async_fire_time_changed( - hass, utcnow + timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2) - ) + freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL * 2)) + async_fire_time_changed(hass) await hass.async_block_till_done() sensor = hass.states.get("sensor.xbt_usd_ask") diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 600fe1c9d24..2b3f5927bd2 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -1,8 +1,8 @@ """Test the LaCrosse View initialization.""" -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import patch -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from lacrosse_view import HTTPError, LoginError from homeassistant.components.lacrosse_view.const import DOMAIN @@ -74,7 +74,7 @@ async def test_http_error(hass: HomeAssistant) -> None: assert entries[0].state == ConfigEntryState.SETUP_RETRY -async def test_new_token(hass: HomeAssistant) -> None: +async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test new token.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) @@ -92,19 +92,20 @@ async def test_new_token(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state == ConfigEntryState.LOADED - one_hour_after = datetime.utcnow() + timedelta(hours=1) - with patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR], - ), freeze_time(one_hour_after): - async_fire_time_changed(hass, one_hour_after) + ): + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() login.assert_called_once() -async def test_failed_token(hass: HomeAssistant) -> None: +async def test_failed_token( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test if a reauth flow occurs when token refresh fails.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) @@ -122,12 +123,9 @@ async def test_failed_token(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state == ConfigEntryState.LOADED - one_hour_after = datetime.utcnow() + timedelta(hours=1) - - with patch( - "lacrosse_view.LaCrosse.login", side_effect=LoginError("Test") - ), freeze_time(one_hour_after): - async_fire_time_changed(hass, one_hour_after) + with patch("lacrosse_view.LaCrosse.login", side_effect=LoginError("Test")): + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index e28ebe695b3..5ed2a397ccd 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -2,6 +2,7 @@ import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import serial from syrupy import SnapshotAssertion @@ -122,7 +123,9 @@ async def test_create_sensors( @patch(API_HEAT_METER_SERVICE) -async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> None: +async def test_exception_on_polling( + mock_heat_meter, hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test sensor.""" entry_data = { "device": "/dev/USB0", @@ -148,7 +151,8 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non # Now 'disable' the connection and wait for polling and see if it fails mock_heat_meter().read.side_effect = serial.serialutil.SerialException - async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + freezer.tick(POLLING_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state.state == STATE_UNAVAILABLE @@ -159,7 +163,8 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non mock_heat_meter().read.return_value = mock_heat_meter_response mock_heat_meter().read.side_effect = None - async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + freezer.tick(POLLING_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state diff --git a/tests/components/lastfm/snapshots/test_sensor.ambr b/tests/components/lastfm/snapshots/test_sensor.ambr index e64cf6b2629..30ad40df428 100644 --- a/tests/components/lastfm/snapshots/test_sensor.ambr +++ b/tests/components/lastfm/snapshots/test_sensor.ambr @@ -4,14 +4,14 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Last.fm', 'entity_picture': 'image', - 'friendly_name': 'testaccount1', + 'friendly_name': 'LastFM testaccount1', 'icon': 'mdi:radio-fm', 'last_played': 'artist - title', 'play_count': 1, 'top_played': 'artist - title', }), 'context': , - 'entity_id': 'sensor.testaccount1', + 'entity_id': 'sensor.lastfm_testaccount1', 'last_changed': , 'last_updated': , 'state': 'artist - title', @@ -22,14 +22,14 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Last.fm', 'entity_picture': 'image', - 'friendly_name': 'testaccount1', + 'friendly_name': 'LastFM testaccount1', 'icon': 'mdi:radio-fm', 'last_played': None, 'play_count': 0, 'top_played': None, }), 'context': , - 'entity_id': 'sensor.testaccount1', + 'entity_id': 'sensor.lastfm_testaccount1', 'last_changed': , 'last_updated': , 'state': 'Not Scrobbling', diff --git a/tests/components/lastfm/test_init.py b/tests/components/lastfm/test_init.py index 8f731385e6f..2f126af11a3 100644 --- a/tests/components/lastfm/test_init.py +++ b/tests/components/lastfm/test_init.py @@ -20,11 +20,11 @@ async def test_load_unload_entry( await setup_integration(config_entry, default_user) entry = hass.config_entries.async_entries(DOMAIN)[0] - state = hass.states.get("sensor.testaccount1") + state = hass.states.get("sensor.lastfm_testaccount1") assert state await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.testaccount1") + state = hass.states.get("sensor.lastfm_testaccount1") assert not state diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index 049f2a74250..f5723215e2a 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -4,10 +4,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lastfm.const import ( - CONF_USERS, - DOMAIN, -) +from homeassistant.components.lastfm.const import CONF_USERS, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant @@ -58,7 +55,7 @@ async def test_sensors( user = request.getfixturevalue(fixture) await setup_integration(config_entry, user) - entity_id = "sensor.testaccount1" + entity_id = "sensor.lastfm_testaccount1" state = hass.states.get(entity_id) diff --git a/tests/components/lawn_mower/__init__.py b/tests/components/lawn_mower/__init__.py new file mode 100644 index 00000000000..0f96921206e --- /dev/null +++ b/tests/components/lawn_mower/__init__.py @@ -0,0 +1 @@ +"""Tests for the lawn mower integration.""" diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py new file mode 100644 index 00000000000..39d594e1e17 --- /dev/null +++ b/tests/components/lawn_mower/test_init.py @@ -0,0 +1,178 @@ +"""The tests for the lawn mower integration.""" +from collections.abc import Generator +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +class MockLawnMowerEntity(LawnMowerEntity): + """Mock lawn mower device to use in tests.""" + + def __init__( + self, + unique_id: str = "mock_lawn_mower", + name: str = "Lawn Mower", + features: LawnMowerEntityFeature = LawnMowerEntityFeature(0), + ) -> None: + """Initialize the lawn mower.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + + def start_mowing(self) -> None: + """Start mowing.""" + self._attr_activity = LawnMowerActivity.MOWING + + +@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 + + +async def test_lawn_mower_setup(hass: HomeAssistant) -> None: + """Test setup and tear down of lawn mower platform and entity.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup( + config_entry, Platform.LAWN_MOWER + ) + 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.LAWN_MOWER] + ) + 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, + ), + ) + + entity1 = MockLawnMowerEntity() + entity1.entity_id = "lawn_mower.mock_lawn_mower" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test platform via config entry.""" + async_add_entities([entity1]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{LAWN_MOWER_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity1.entity_id) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + entity_state = hass.states.get(entity1.entity_id) + + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + +async def test_sync_start_mowing(hass: HomeAssistant) -> None: + """Test if async mowing calls sync mowing.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.start_mowing = MagicMock() + await lawn_mower.async_start_mowing() + + assert lawn_mower.start_mowing.called + + +async def test_sync_dock(hass: HomeAssistant) -> None: + """Test if async dock calls sync dock.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.dock = MagicMock() + await lawn_mower.async_dock() + + assert lawn_mower.dock.called + + +async def test_sync_pause(hass: HomeAssistant) -> None: + """Test if async pause calls sync pause.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.pause = MagicMock() + await lawn_mower.async_pause() + + assert lawn_mower.pause.called + + +async def test_lawn_mower_default(hass: HomeAssistant) -> None: + """Test lawn mower entity with defaults.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + assert lawn_mower.state is None + + +async def test_lawn_mower_state(hass: HomeAssistant) -> None: + """Test lawn mower entity returns state.""" + lawn_mower = MockLawnMowerEntity( + "lawn_mower_1", "Test lawn mower", LawnMowerActivity.MOWING + ) + lawn_mower.hass = hass + lawn_mower.start_mowing() + + assert lawn_mower.state == str(LawnMowerActivity.MOWING) diff --git a/tests/components/lidarr/test_config_flow.py b/tests/components/lidarr/test_config_flow.py index d3c4352dc1e..89bb6614739 100644 --- a/tests/components/lidarr/test_config_flow.py +++ b/tests/components/lidarr/test_config_flow.py @@ -1,9 +1,9 @@ """Test Lidarr config flow.""" -from homeassistant import data_entry_flow from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from .conftest import CONF_DATA, MOCK_INPUT, ComponentSetup @@ -15,14 +15,14 @@ async def test_flow_user_form(hass: HomeAssistant, connection) -> None: context={CONF_SOURCE: SOURCE_USER}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_INPUT, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == CONF_DATA @@ -34,7 +34,7 @@ async def test_flow_user_invalid_auth(hass: HomeAssistant, invalid_auth) -> None context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" @@ -47,7 +47,7 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant, cannot_connect) -> data=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" @@ -60,7 +60,7 @@ async def test_wrong_app(hass: HomeAssistant, wrong_app) -> None: data=MOCK_INPUT, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "wrong_app" @@ -73,7 +73,7 @@ async def test_zeroconf_failed(hass: HomeAssistant, zeroconf_failed) -> None: data=MOCK_INPUT, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "zeroconf_failed" @@ -88,7 +88,7 @@ async def test_flow_user_unknown_error(hass: HomeAssistant, unknown) -> None: result["flow_id"], user_input=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "unknown" @@ -108,18 +108,18 @@ async def test_flow_reauth( }, data=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_API_KEY: "abc123"}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert entry.data[CONF_API_KEY] == "abc123" diff --git a/tests/components/life360/test_config_flow.py b/tests/components/life360/test_config_flow.py index b71b6638eb6..7eec67fc0cc 100644 --- a/tests/components/life360/test_config_flow.py +++ b/tests/components/life360/test_config_flow.py @@ -274,6 +274,15 @@ async def test_reauth_config_flow_login_error( 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 diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py index 4b583eed475..d71a7eeaf0b 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -21,7 +21,6 @@ from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, SERIAL, _mocked_clean_bulb, _patch_config_flow_try_connect, @@ -38,7 +37,7 @@ async def test_hev_cycle_state(hass: HomeAssistant) -> None: domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_clean_bulb() diff --git a/tests/components/lifx/test_button.py b/tests/components/lifx/test_button.py index b166aa05d66..d527229fe78 100644 --- a/tests/components/lifx/test_button.py +++ b/tests/components/lifx/test_button.py @@ -14,7 +14,6 @@ from homeassistant.setup import async_setup_component from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, SERIAL, _mocked_bulb, _patch_config_flow_try_connect, @@ -38,7 +37,7 @@ async def test_button_restart(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() @@ -70,7 +69,7 @@ async def test_button_identify(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 581f0516184..a72695502a4 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -7,7 +7,7 @@ from homeassistant.setup import async_setup_component from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, + SERIAL, _mocked_bulb, _mocked_clean_bulb, _mocked_infrared_bulb, @@ -30,7 +30,7 @@ async def test_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() @@ -77,7 +77,7 @@ async def test_clean_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_clean_bulb() @@ -129,7 +129,7 @@ async def test_infrared_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -177,7 +177,7 @@ async def test_legacy_multizone_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() @@ -288,7 +288,7 @@ async def test_multizone_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() diff --git a/tests/components/lifx/test_init.py b/tests/components/lifx/test_init.py index 3c813840faf..3f16cc44f41 100644 --- a/tests/components/lifx/test_init.py +++ b/tests/components/lifx/test_init.py @@ -5,6 +5,8 @@ from datetime import timedelta import socket from unittest.mock import patch +import pytest + from homeassistant.components import lifx from homeassistant.components.lifx import DOMAIN, discovery from homeassistant.config_entries import ConfigEntryState @@ -149,3 +151,23 @@ async def test_dns_error_at_startup(hass: HomeAssistant) -> None: await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_wrong_serial( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test config entry enters setup retry when serial mismatches.""" + mismatched_serial = f"{SERIAL[:-1]}0" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=mismatched_serial + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_discovery(), _patch_config_flow_try_connect(), _patch_device(): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + + assert ( + "Unexpected device found at 127.0.0.1; expected aa:bb:cc:dd:ee:c0, found aa:bb:cc:dd:ee:cc" + in caplog.text + ) diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index d190cbe6b10..aa705418d55 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -13,7 +13,6 @@ from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, SERIAL, MockLifxCommand, _mocked_infrared_bulb, @@ -32,7 +31,7 @@ async def test_theme_select(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() @@ -70,7 +69,7 @@ async def test_infrared_brightness(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -100,7 +99,7 @@ async def test_set_infrared_brightness_25_percent(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -139,7 +138,7 @@ async def test_set_infrared_brightness_50_percent(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -178,7 +177,7 @@ async def test_set_infrared_brightness_100_percent(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -217,7 +216,7 @@ async def test_disable_infrared(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -256,7 +255,7 @@ async def test_invalid_infrared_brightness(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index a36e151849b..5fe69c8dabc 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -20,7 +20,7 @@ from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, + SERIAL, _mocked_bulb, _mocked_bulb_old_firmware, _patch_config_flow_try_connect, @@ -38,7 +38,7 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() @@ -89,7 +89,7 @@ async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb_old_firmware() diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index dcb52d68a79..05483b46d97 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -14,10 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider from homeassistant.setup import async_setup_component diff --git a/tests/components/litejet/test_config_flow.py b/tests/components/litejet/test_config_flow.py index 69cd1d3d2e3..e2b2829de9e 100644 --- a/tests/components/litejet/test_config_flow.py +++ b/tests/components/litejet/test_config_flow.py @@ -6,7 +6,8 @@ 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 HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -67,7 +68,7 @@ async def test_flow_open_failed(hass: HomeAssistant) -> None: assert result["errors"][CONF_PORT] == "open_failed" -async def test_import_step(hass: HomeAssistant) -> None: +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( @@ -78,6 +79,51 @@ async def test_import_step(hass: HomeAssistant) -> None: 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.""" diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index f5b4e32a1e1..5bf6fb7cce6 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -142,3 +142,17 @@ FEEDER_ROBOT_DATA = { } VACUUM_ENTITY_ID = "vacuum.test_litter_box" + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 8d340f40515..170d6313029 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -1,5 +1,5 @@ """Test Litter-Robot setup process.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException import pytest @@ -13,14 +13,18 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component -from .common import CONFIG, VACUUM_ENTITY_ID +from .common import CONFIG, VACUUM_ENTITY_ID, remove_device from .conftest import setup_integration from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator -async def test_unload_entry(hass: HomeAssistant, mock_account) -> None: +async def test_unload_entry(hass: HomeAssistant, mock_account: MagicMock) -> None: """Test being able to unload an entry.""" entry = await setup_integration(hass, mock_account, VACUUM_DOMAIN) @@ -49,7 +53,9 @@ async def test_unload_entry(hass: HomeAssistant, mock_account) -> None: ), ) async def test_entry_not_setup( - hass: HomeAssistant, side_effect, expected_state + hass: HomeAssistant, + side_effect: LitterRobotException, + expected_state: ConfigEntryState, ) -> None: """Test being able to handle config entry not setup.""" entry = MockConfigEntry( @@ -64,3 +70,35 @@ async def test_entry_not_setup( ): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is expected_state + + +async def test_device_remove_devices( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_account: MagicMock +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + config_entry = await setup_integration(hass, mock_account, VACUUM_DOMAIN) + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities[VACUUM_ENTITY_ID] + assert entity.unique_id == "LR3C012345-litter_box" + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, config_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(litterrobot.DOMAIN, "test-serial", "remove-serial")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 0d33881c46c..24b13d48a1e 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -73,7 +73,6 @@ async def test_lock_default(hass: HomeAssistant) -> None: async def test_lock_states(hass: HomeAssistant) -> None: """Test lock entity states.""" - # pylint: disable=protected-access lock = MockLockEntity() lock.hass = hass diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 2a93e6e1d4c..eaa2a1e4192 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -1,5 +1,4 @@ """The tests for the logbook component.""" -# pylint: disable=invalid-name import asyncio import collections from collections.abc import Callable diff --git a/tests/components/logi_circle/test_config_flow.py b/tests/components/logi_circle/test_config_flow.py index 885459a5df2..de4a9bd4da4 100644 --- a/tests/components/logi_circle/test_config_flow.py +++ b/tests/components/logi_circle/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.components.logi_circle.config_flow import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry class MockRequest: @@ -50,10 +50,12 @@ def mock_logi_circle(): with patch( "homeassistant.components.logi_circle.config_flow.LogiCircle" ) as logi_circle: + future = asyncio.Future() + future.set_result({"accountId": "testId"}) LogiCircle = logi_circle() LogiCircle.authorize = AsyncMock(return_value=True) LogiCircle.close = AsyncMock(return_value=True) - LogiCircle.account = mock_coro(return_value={"accountId": "testId"}) + LogiCircle.account = future LogiCircle.authorize_url = "http://authorize.url" yield LogiCircle diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index e0cae290e2b..5a5333a42d5 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -40,6 +40,9 @@ def _create_message(idx: int) -> dict[str, Any]: class TestMailbox(mailbox.Mailbox): """Test Mailbox, with 10 sample messages.""" + # This class doesn't contain any tests! Skip pytest test collection. + __test__ = False + def __init__(self, hass: HomeAssistant, name: str) -> None: """Initialize Test mailbox.""" super().__init__(hass, name) diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index 3eba65dc8ab..221ae891d67 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,10 +14,7 @@ from homeassistant.components.lock import ( from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .common import ( - set_node_attribute, - trigger_subscription_callback, -) +from .common import set_node_attribute, trigger_subscription_callback # This tests needs to be adjusted to remove lingering tasks diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 911dd0fe389..0aa9385a74c 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -5,16 +5,10 @@ from matter_server.client.models.node import MatterNode from matter_server.common.models import EventType, MatterNodeEvent import pytest -from homeassistant.components.event import ( - ATTR_EVENT_TYPE, - ATTR_EVENT_TYPES, -) +from homeassistant.components.event import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES from homeassistant.core import HomeAssistant -from .common import ( - setup_integration_with_node_fixture, - trigger_subscription_callback, -) +from .common import setup_integration_with_node_fixture, trigger_subscription_callback @pytest.fixture(name="generic_switch_node") @@ -54,7 +48,7 @@ async def test_generic_switch_node( assert state.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", "multi_press_ongoing", "multi_press_complete", @@ -117,7 +111,7 @@ async def test_generic_switch_multi_node( assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", ] # check button 2 diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index 28f4479432c..36761362618 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -43,6 +43,7 @@ async def test_get_node_from_device_entry( device_registry = dr.async_get(hass) other_domain = "other_domain" other_config_entry = MockConfigEntry(domain=other_domain) + other_config_entry.add_to_hass(hass) other_device_entry = device_registry.async_get_or_create( config_entry_id=other_config_entry.entry_id, identifiers={(other_domain, "1234")}, diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 86436ac4184..3556f687989 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -259,8 +259,10 @@ async def test_service_device_id_not_mazda_vehicle(hass: HomeAssistant) -> None: device_registry = dr.async_get(hass) # Create another device and pass its device ID. # Service should fail because device is from wrong domain. + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) other_device = device_registry.async_get_or_create( - config_entry_id="test_config_entry_id", + config_entry_id=other_config_entry.entry_id, identifiers={("OTHER_INTEGRATION", "ID_FROM_OTHER_INTEGRATION")}, ) diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index b81aacf0040..8f877eb1eca 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -7,9 +7,10 @@ from aiohttp import ClientError, ClientResponseError import pymelcloud import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.melcloud.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -119,6 +120,106 @@ 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: @@ -144,6 +245,12 @@ async def test_import_with_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.""" diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index ab51bf44a57..3e87a4e646f 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Generator -from datetime import datetime, time, timedelta, timezone +from datetime import UTC, datetime, time, timedelta from unittest.mock import AsyncMock, patch from melnor_bluetooth.device import Device @@ -73,7 +73,7 @@ class MockFrequency: self._interval = 0 self._is_watering = False self._start_time = time(12, 0) - self._next_run_time = datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc) + self._next_run_time = datetime(2021, 1, 1, 12, 0, tzinfo=UTC) @property def duration_minutes(self) -> int: diff --git a/tests/components/met/conftest.py b/tests/components/met/conftest.py index e6b975023d1..a007620988f 100644 --- a/tests/components/met/conftest.py +++ b/tests/components/met/conftest.py @@ -17,6 +17,7 @@ def mock_weather(): "humidity": 50, "wind_speed": 10, "wind_bearing": "NE", + "dew_point": 12.1, } mock_data.get_forecast.return_value = {} yield mock_data diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 2941935fcfc..5a28b8eceb0 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -6,6 +6,33 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("weather")) == 1 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "home-hourly", + ) + await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("weather")) == 2 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: """Test we track home.""" await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) @@ -13,17 +40,6 @@ async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: assert len(hass.states.async_entity_ids("weather")) == 1 assert len(mock_weather.mock_calls) == 4 - # Test the hourly sensor is disabled by default - registry = er.async_get(hass) - - state = hass.states.get("weather.forecast_test_home_hourly") - assert state is None - - entry = registry.async_get("weather.forecast_test_home_hourly") - assert entry - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - # Test we track config await hass.config.async_update(latitude=10, longitude=20) await hass.async_block_till_done() @@ -44,23 +60,13 @@ async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: async def test_not_tracking_home(hass: HomeAssistant, mock_weather) -> None: """Test when we not track home.""" - # Pre-create registry entry for disabled by default hourly weather - registry = er.async_get(hass) - registry.async_get_or_create( - WEATHER_DOMAIN, - DOMAIN, - "10-20-hourly", - suggested_object_id="forecast_somewhere_hourly", - disabled_by=None, - ) - await hass.config_entries.flow.async_init( "met", context={"source": config_entries.SOURCE_USER}, data={"name": "Somewhere", "latitude": 10, "longitude": 20, "elevation": 0}, ) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids("weather")) == 2 + assert len(hass.states.async_entity_ids("weather")) == 1 assert len(mock_weather.mock_calls) == 4 # Test we do not track config diff --git a/tests/components/met_eireann/snapshots/test_weather.ambr b/tests/components/met_eireann/snapshots/test_weather.ambr new file mode 100644 index 00000000000..81d7a52aa06 --- /dev/null +++ b/tests/components/met_eireann/snapshots/test_weather.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]), + }) +# --- +# name: test_forecast_subscription[daily] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]) +# --- +# name: test_forecast_subscription[daily].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 15.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 25.0, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 10.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 20.0, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-08T12:00:00+00:00', + 'temperature': 15.0, + }), + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2023-08-09T12:00:00+00:00', + 'temperature': 25.0, + }), + ]) +# --- diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index 6983a47ff4b..a3ca1fd55f7 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -1,13 +1,26 @@ """Test Met Éireann weather entity.""" +import datetime + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.met_eireann import UPDATE_INTERVAL from homeassistant.components.met_eireann.const import DOMAIN +from homeassistant.components.weather import ( + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator -async def test_weather(hass: HomeAssistant, mock_weather) -> None: - """Test weather entity.""" - # Create a mock configuration for testing +async def setup_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a mock configuration for testing.""" mock_data = MockConfigEntry( domain=DOMAIN, data={"name": "Somewhere", "latitude": 10, "longitude": 20, "elevation": 0}, @@ -16,6 +29,37 @@ async def test_weather(hass: HomeAssistant, mock_weather) -> None: await hass.config_entries.async_setup(mock_data.entry_id) await hass.async_block_till_done() + return mock_data + + +async def test_new_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + await setup_config_entry(hass) + assert len(hass.states.async_entity_ids("weather")) == 1 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +async def test_legacy_config_entry(hass: HomeAssistant, mock_weather) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "10-20-hourly", + ) + await setup_config_entry(hass) + assert len(hass.states.async_entity_ids("weather")) == 2 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + +async def test_weather(hass: HomeAssistant, mock_weather) -> None: + """Test weather entity.""" + await setup_config_entry(hass) assert len(hass.states.async_entity_ids("weather")) == 1 assert len(mock_weather.mock_calls) == 4 @@ -29,3 +73,123 @@ async def test_weather(hass: HomeAssistant, mock_weather) -> None: await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_entity_ids("weather")) == 0 + + +async def test_forecast_service( + hass: HomeAssistant, + mock_weather, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + mock_weather.get_forecast.return_value = [ + { + "condition": "SleetSunThunder", + "datetime": datetime.datetime(2023, 8, 8, 12, 0, tzinfo=datetime.UTC), + "temperature": 10.0, + }, + { + "condition": "SleetSunThunder", + "datetime": datetime.datetime(2023, 8, 9, 12, 0, tzinfo=datetime.UTC), + "temperature": 20.0, + }, + ] + + await setup_config_entry(hass) + assert len(hass.states.async_entity_ids("weather")) == 1 + entity_id = hass.states.async_entity_ids("weather")[0] + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot + + +@pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + mock_weather, + snapshot: SnapshotAssertion, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + mock_weather.get_forecast.return_value = [ + { + "condition": "SleetSunThunder", + "datetime": datetime.datetime(2023, 8, 8, 12, 0, tzinfo=datetime.UTC), + "temperature": 10.0, + }, + { + "condition": "SleetSunThunder", + "datetime": datetime.datetime(2023, 8, 9, 12, 0, tzinfo=datetime.UTC), + "temperature": 20.0, + }, + ] + + await setup_config_entry(hass) + assert len(hass.states.async_entity_ids("weather")) == 1 + entity_id = hass.states.async_entity_ids("weather")[0] + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 == snapshot + + mock_weather.get_forecast.return_value = [ + { + "condition": "SleetSunThunder", + "datetime": datetime.datetime(2023, 8, 8, 12, 0, tzinfo=datetime.UTC), + "temperature": 15.0, + }, + { + "condition": "SleetSunThunder", + "datetime": datetime.datetime(2023, 8, 9, 12, 0, tzinfo=datetime.UTC), + "temperature": 25.0, + }, + ] + + freezer.tick(UPDATE_INTERVAL + datetime.timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 == snapshot diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py index e405d74ad53..80155d3311a 100644 --- a/tests/components/meteo_france/test_config_flow.py +++ b/tests/components/meteo_france/test_config_flow.py @@ -5,14 +5,9 @@ from meteofrance_api.model import Place import pytest from homeassistant import data_entry_flow -from homeassistant.components.meteo_france.const import ( - CONF_CITY, - DOMAIN, - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, -) +from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -212,36 +207,3 @@ async def test_abort_if_already_setup(hass: HomeAssistant, client_single) -> Non ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, - unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", - ) - config_entry.add_to_hass(hass) - - assert config_entry.options == {} - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - - # Default - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_MODE] == FORECAST_MODE_DAILY - - # Manual - 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_MODE: FORECAST_MODE_HOURLY}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_MODE] == FORECAST_MODE_HOURLY diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 323c958eb22..84fcfabffee 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -35,6 +35,7 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: """Create device registry devices so the device tracker entities are enabled.""" dev_reg = dr.async_get(hass) config_entry = MockConfigEntry(domain="something_else") + config_entry.add_to_hass(hass) for idx, device in enumerate( ( diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index 2c17a2d7550..694e9537a8c 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: @@ -109,7 +109,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch.object( - hass.config_entries, "async_forward_entry_unload", return_value=mock_coro(True) + hass.config_entries, + "async_forward_entry_unload", + return_value=True, ) as unload_entry, patch( "mill.Mill.fetch_heater_and_sensor_data", return_value={} ), patch( diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index ac5ae7dbc6e..3a201f15bf3 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Minecraft Server config flow.""" -import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import aiodns from mcstatus.status_response import JavaStatusResponse @@ -72,9 +71,6 @@ USER_INPUT_PORT_TOO_LARGE = { CONF_HOST: "mc.dummyserver.com:65536", } -SRV_RECORDS = asyncio.Future() -SRV_RECORDS.set_result([QueryMock()]) - async def test_show_config_form(hass: HomeAssistant) -> None: """Test if initial configuration form is shown.""" @@ -173,7 +169,7 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None """Test config entry in case of a successful connection with a SRV record.""" with patch( "aiodns.DNSResolver.query", - return_value=SRV_RECORDS, + side_effect=AsyncMock(return_value=[QueryMock()]), ), patch( "mcstatus.server.JavaServer.async_status", return_value=JavaStatusResponse( diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index 875fccea294..e7c9ad4995a 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -1,7 +1,6 @@ """Tests for mobile_app component.""" from http import HTTPStatus -# pylint: disable=unused-import import pytest from homeassistant.components.mobile_app.const import DOMAIN diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index 4b9169b48db..28a8a26657a 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -13,7 +13,7 @@ from homeassistant.setup import async_setup_component from .const import REGISTER, REGISTER_CLEARTEXT, RENDER_TEMPLATE -from tests.common import MockUser, mock_coro +from tests.common import MockUser from tests.typing import ClientSessionGenerator @@ -28,7 +28,6 @@ async def test_registration( with patch( "homeassistant.components.person.async_add_user_device_tracker", spec=True, - return_value=mock_coro(), ) as add_user_dev_track: resp = await api_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 21c5f4ddb25..23d3ee522bb 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -5,11 +5,13 @@ from datetime import timedelta import logging from unittest import mock +from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SLAVE, CONF_TYPE +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -140,26 +142,26 @@ async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): @pytest.fixture(name="mock_do_cycle") -async def mock_do_cycle_fixture(hass, mock_pymodbus_exception, mock_pymodbus_return): +async def mock_do_cycle_fixture( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_pymodbus_exception, + mock_pymodbus_return, +) -> FrozenDateTimeFactory: """Trigger update call with time_changed event.""" - now = dt_util.utcnow() + timedelta(seconds=90) - with mock.patch( - "homeassistant.helpers.event.dt_util.utcnow", return_value=now, autospec=True - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() - return now + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + return freezer -async def do_next_cycle(hass, now, cycle): +async def do_next_cycle( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, cycle: int +) -> None: """Trigger update call with time_changed event.""" - now += timedelta(seconds=cycle) - with mock.patch( - "homeassistant.helpers.event.dt_util.utcnow", return_value=now, autospec=True - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() - return now + freezer.tick(timedelta(seconds=cycle)) + async_fire_time_changed(hass) + await hass.async_block_till_done() @pytest.fixture(name="mock_test_state") diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 5c4535f9f29..1e413fcc764 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,4 +1,5 @@ """Thetests for the Modbus sensor component.""" +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -199,14 +200,13 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - ], ) async def test_lazy_error_binary_sensor( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle + hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory ) -> None: """Run test for given config.""" - now = mock_do_cycle assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == end_expect diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 62b3fb6e7f4..4ab78df0c81 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,4 +1,5 @@ """The tests for the Modbus climate component.""" +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN @@ -22,6 +23,8 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_ONOFF_REGISTER, CONF_LAZY_ERROR, CONF_TARGET_TEMP, + CONF_TARGET_TEMP_WRITE_REGISTERS, + CONF_WRITE_REGISTERS, MODBUS_DOMAIN, DataType, ) @@ -78,6 +81,19 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_TARGET_TEMP_WRITE_REGISTERS: True, + CONF_WRITE_REGISTERS: True, + } + ], + }, { CONF_CLIMATES: [ { @@ -101,6 +117,30 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_WRITE_REGISTERS: True, + CONF_HVAC_MODE_VALUES: { + "state_off": 0, + "state_heat": 1, + "state_cool": 2, + "state_heat_cool": 3, + "state_dry": 4, + "state_fan_only": 5, + "state_auto": 6, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: @@ -353,6 +393,22 @@ async def test_service_climate_update( ] }, ), + ( + 25, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: DataType.INT16, + CONF_TARGET_TEMP_WRITE_REGISTERS: True, + } + ] + }, + ), ], ) async def test_service_climate_set_temperature( @@ -417,6 +473,52 @@ async def test_service_climate_set_temperature( ] }, ), + ( + HVACMode.HEAT, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 1, + CONF_HVAC_MODE_HEAT: 2, + }, + CONF_WRITE_REGISTERS: True, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + } + ] + }, + ), + ( + HVACMode.OFF, + [0x00], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 1, + CONF_HVAC_MODE_HEAT: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + CONF_WRITE_REGISTERS: True, + } + ] + }, + ), ], ) async def test_service_set_mode( @@ -496,16 +598,15 @@ async def test_restore_state_climate( ], ) async def test_lazy_error_climate( - hass: HomeAssistant, mock_do_cycle, start_expect, end_expect + hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect ) -> None: """Run test for sensor.""" hass.states.async_set(ENTITY_ID, 17) await hass.async_block_till_done() - now = mock_do_cycle assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == end_expect diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 4ec1b9d7bfc..66e4537d67e 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -1,4 +1,5 @@ """The tests for the Modbus cover component.""" +from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -142,14 +143,13 @@ async def test_coil_cover(hass: HomeAssistant, expected, mock_do_cycle) -> None: ], ) async def test_lazy_error_cover( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle + hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory ) -> None: """Run test for given config.""" - now = mock_do_cycle assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == end_expect diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 908e7209fb9..6f88a4b7399 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -49,6 +49,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_WORD, + CONF_SWAP_WORD_BYTE, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, @@ -64,6 +65,7 @@ from homeassistant.components.modbus.const import ( from homeassistant.components.modbus.validators import ( duplicate_entity_validator, duplicate_modbus_validator, + nan_validator, number_validator, struct_validator, ) @@ -141,6 +143,23 @@ async def test_number_validator() -> None: pytest.fail("Number_validator not throwing exception") +async def test_nan_validator() -> None: + """Test number validator.""" + + for value, value_type in ( + (15, int), + ("15", int), + ("abcdef", int), + ("0xabcdef", int), + ): + assert isinstance(nan_validator(value), value_type) + + with pytest.raises(vol.Invalid): + nan_validator("x15") + with pytest.raises(vol.Invalid): + nan_validator("not a hex string") + + @pytest.mark.parametrize( "do_config", [ @@ -164,6 +183,30 @@ async def test_number_validator() -> None: CONF_DATA_TYPE: DataType.CUSTOM, CONF_STRUCTURE: ">i", }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 5, + CONF_DATA_TYPE: DataType.INT32, + CONF_SWAP: CONF_SWAP_BYTE, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 5, + CONF_DATA_TYPE: DataType.INT32, + CONF_SWAP: CONF_SWAP_WORD, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 5, + CONF_DATA_TYPE: DataType.INT32, + CONF_SWAP: CONF_SWAP_WORD_BYTE, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_SLAVE: 5, + CONF_DATA_TYPE: DataType.INT16, + CONF_SWAP: CONF_SWAP_BYTE, + }, ], ) async def test_ok_struct_validator(do_config) -> None: @@ -236,6 +279,16 @@ async def test_ok_struct_validator(do_config) -> None: CONF_SLAVE_COUNT: 2, CONF_DATA_TYPE: DataType.INT32, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_DATA_TYPE: DataType.INT16, + CONF_SWAP: CONF_SWAP_WORD, + }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_DATA_TYPE: DataType.INT16, + CONF_SWAP: CONF_SWAP_WORD_BYTE, + }, ], ) async def test_exception_struct_validator(do_config) -> None: @@ -926,3 +979,20 @@ async def test_integration_reload_failed( assert "Modbus reloading" in caplog.text assert "connect failed, retry in pymodbus" in caplog.text + + +@pytest.mark.parametrize("do_config", [{}]) +async def test_integration_setup_failed( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus +) -> None: + """Run test for integration setup on reload.""" + with mock.patch.object( + hass_config, + "YAML_CONFIG_FILE", + get_fixture_path("configuration.yaml", "modbus"), + ): + hass.data[DOMAIN][TEST_MODBUS_NAME].async_setup = mock.AsyncMock( + return_value=False + ) + await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) + await hass.async_block_till_done() diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index e973f226913..f72371ed42e 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,4 +1,5 @@ """The tests for the Modbus sensor component.""" +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.modbus.const import ( @@ -9,6 +10,7 @@ from homeassistant.components.modbus.const import ( CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, + CONF_NAN_VALUE, CONF_PRECISION, CONF_SCALE, CONF_SLAVE_COUNT, @@ -46,6 +48,8 @@ from homeassistant.setup import async_setup_component from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from tests.common import mock_restore_cache_with_extra_data + ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") SLAVE_UNIQUE_ID = "ground_floor_sensor" @@ -502,7 +506,7 @@ async def test_config_wrong_struct_sensor( }, [0x0102], False, - str(int(0x0102)), + str(0x0102), ), ( { @@ -511,7 +515,7 @@ async def test_config_wrong_struct_sensor( }, [0x0201], False, - str(int(0x0102)), + str(0x0102), ), ( { @@ -520,7 +524,7 @@ async def test_config_wrong_struct_sensor( }, [0x0102, 0x0304], False, - str(int(0x02010403)), + str(0x02010403), ), ( { @@ -529,7 +533,7 @@ async def test_config_wrong_struct_sensor( }, [0x0102, 0x0304], False, - str(int(0x03040102)), + str(0x03040102), ), ( { @@ -538,43 +542,52 @@ async def test_config_wrong_struct_sensor( }, [0x0102, 0x0304], False, - str(int(0x04030201)), + str(0x04030201), ), ( { CONF_DATA_TYPE: DataType.INT32, - CONF_MAX_VALUE: int(0x02010400), + CONF_MAX_VALUE: 0x02010400, }, [0x0201, 0x0403], False, - str(int(0x02010400)), + str(0x02010400), ), ( { CONF_DATA_TYPE: DataType.INT32, - CONF_MIN_VALUE: int(0x02010404), + CONF_MIN_VALUE: 0x02010404, }, [0x0201, 0x0403], False, - str(int(0x02010404)), + str(0x02010404), ), ( { CONF_DATA_TYPE: DataType.INT32, - CONF_ZERO_SUPPRESS: int(0x00000001), + CONF_NAN_VALUE: "0x80000000", }, - [0x0000, 0x0002], + [0x8000, 0x0000], False, - str(int(0x00000002)), + STATE_UNAVAILABLE, ), ( { CONF_DATA_TYPE: DataType.INT32, - CONF_ZERO_SUPPRESS: int(0x00000002), + CONF_ZERO_SUPPRESS: 0x00000001, }, [0x0000, 0x0002], False, - str(int(0)), + str(0x00000002), + ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_ZERO_SUPPRESS: 0x00000002, + }, + [0x0000, 0x0002], + False, + str(0), ), ( { @@ -715,7 +728,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non }, [0x0102], False, - [str(int(0x0201))], + [str(0x0201)], ), ( { @@ -726,7 +739,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non }, [0x0102, 0x0304], False, - [str(int(0x03040102))], + [str(0x03040102)], ), ( { @@ -737,7 +750,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non }, [0x0102, 0x0304, 0x0506, 0x0708], False, - [str(int(0x0708050603040102))], + [str(0x0708050603040102)], ), ( { @@ -748,7 +761,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non }, [0x0102, 0x0304], False, - [str(int(0x0201)), str(int(0x0403))], + [str(0x0201), str(0x0403)], ), ( { @@ -759,7 +772,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non }, [0x0102, 0x0304, 0x0506, 0x0708], False, - [str(int(0x03040102)), str(int(0x07080506))], + [str(0x03040102), str(0x07080506)], ), ( { @@ -770,7 +783,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non }, [0x0102, 0x0304, 0x0506, 0x0708, 0x0901, 0x0902, 0x0903, 0x0904], False, - [str(int(0x0708050603040102)), str(int(0x0904090309020901))], + [str(0x0708050603040102), str(0x0904090309020901)], ), ( { @@ -781,7 +794,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non }, [0x0102, 0x0304, 0x0506, 0x0708], False, - [str(int(0x0201)), str(int(0x0403)), str(int(0x0605)), str(int(0x0807))], + [str(0x0201), str(0x0403), str(0x0605), str(0x0807)], ), ( { @@ -802,10 +815,10 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ], False, [ - str(int(0x03040102)), - str(int(0x07080506)), - str(int(0x0B0C090A)), - str(int(0x0F000D0E)), + str(0x03040102), + str(0x07080506), + str(0x0B0C090A), + str(0x0F000D0E), ], ), ( @@ -835,10 +848,10 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ], False, [ - str(int(0x0604060306020601)), - str(int(0x0704070307020701)), - str(int(0x0804080308020801)), - str(int(0x0904090309020901)), + str(0x0604060306020601), + str(0x0704070307020701), + str(0x0804080308020801), + str(0x0904090309020901), ], ), ], @@ -916,16 +929,15 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: ], ) async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle, start_expect, end_expect + hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect ) -> None: """Run test for sensor.""" hass.states.async_set(ENTITY_ID, 17) await hass.async_block_till_done() - now = mock_do_cycle assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == end_expect @@ -1061,23 +1073,27 @@ async def test_wrap_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "mock_test_state", - [(State(ENTITY_ID, "unknown"), State(f"{ENTITY_ID}_1", "119"))], - indirect=True, -) +@pytest.fixture(name="mock_restore") +async def mock_restore(hass): + """Mock restore cache.""" + mock_restore_cache_with_extra_data( + hass, + ( + ( + State(ENTITY_ID, "121"), + {"native_value": "121", "native_unit_of_measurement": "kg"}, + ), + ( + State(ENTITY_ID + "_1", "119"), + {"native_value": "119", "native_unit_of_measurement": "kg"}, + ), + ), + ) + + @pytest.mark.parametrize( "do_config", [ - { - CONF_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 0, - } - ] - }, { CONF_SENSORS: [ { @@ -1091,10 +1107,13 @@ async def test_wrap_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None ], ) async def test_restore_state_sensor( - hass: HomeAssistant, mock_test_state, mock_modbus + hass: HomeAssistant, mock_restore, mock_modbus ) -> None: """Run test for sensor restore state.""" - assert hass.states.get(ENTITY_ID).state == mock_test_state[0].state + state = hass.states.get(ENTITY_ID).state + state2 = hass.states.get(ENTITY_ID + "_1").state + assert state + assert state2 @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 2e2f0081eba..7a79e19869a 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest import mock +from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -237,20 +238,19 @@ async def test_all_switch(hass: HomeAssistant, mock_do_cycle, expected) -> None: ], ) async def test_lazy_error_switch( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle + hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory ) -> None: """Run test for given config.""" - now = mock_do_cycle assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == start_expect - now = await do_next_cycle(hass, now, 11) + await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == end_expect @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, STATE_ON),)], + [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], indirect=True, ) @pytest.mark.parametrize( diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py index 5494e69d9e9..659738ef2c5 100644 --- a/tests/components/motioneye/test_sensor.py +++ b/tests/components/motioneye/test_sensor.py @@ -3,6 +3,7 @@ import copy from datetime import timedelta from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from motioneye_client.const import KEY_ACTIONS from homeassistant.components.motioneye import get_motioneye_device_identifier @@ -14,7 +15,6 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY 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 . import ( TEST_CAMERA, @@ -28,7 +28,9 @@ from . import ( from tests.common import async_fire_time_changed -async def test_sensor_actions(hass: HomeAssistant) -> None: +async def test_sensor_actions( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the actions sensor.""" register_test_entity( hass, @@ -51,7 +53,8 @@ async def test_sensor_actions(hass: HomeAssistant) -> None: # When the next refresh is called return the updated values. client.async_get_cameras = AsyncMock(return_value={"cameras": [updated_camera]}) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) @@ -60,7 +63,8 @@ async def test_sensor_actions(hass: HomeAssistant) -> None: assert entity_state.attributes.get(KEY_ACTIONS) == ["one"] del updated_camera[KEY_ACTIONS] - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) @@ -99,7 +103,9 @@ async def test_sensor_device_info(hass: HomeAssistant) -> None: assert TEST_SENSOR_ACTION_ENTITY_ID in entities_from_device -async def test_sensor_actions_can_be_enabled(hass: HomeAssistant) -> None: +async def test_sensor_actions_can_be_enabled( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Verify the action sensor can be enabled.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -122,10 +128,8 @@ async def test_sensor_actions_can_be_enabled(hass: HomeAssistant) -> None: assert not updated_entry.disabled await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) + freezer.tick(timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(TEST_SENSOR_ACTION_ENTITY_ID) diff --git a/tests/components/motioneye/test_switch.py b/tests/components/motioneye/test_switch.py index f0fe4f1faba..cc193f5fb60 100644 --- a/tests/components/motioneye/test_switch.py +++ b/tests/components/motioneye/test_switch.py @@ -3,6 +3,7 @@ import copy from datetime import timedelta from unittest.mock import AsyncMock, call, patch +from freezegun.api import FrozenDateTimeFactory from motioneye_client.const import ( KEY_MOTION_DETECTION, KEY_MOVIES, @@ -19,7 +20,6 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON 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 . import ( TEST_CAMERA, @@ -34,7 +34,9 @@ from . import ( from tests.common import async_fire_time_changed -async def test_switch_turn_on_off(hass: HomeAssistant) -> None: +async def test_switch_turn_on_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test turning the switch on and off.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -60,7 +62,8 @@ async def test_switch_turn_on_off(hass: HomeAssistant) -> None: blocking=True, ) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Verify correct parameters are passed to the library. @@ -85,7 +88,8 @@ async def test_switch_turn_on_off(hass: HomeAssistant) -> None: # Verify correct parameters are passed to the library. assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, TEST_CAMERA) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Verify the switch turns on. @@ -94,7 +98,9 @@ async def test_switch_turn_on_off(hass: HomeAssistant) -> None: assert entity_state.state == "on" -async def test_switch_state_update_from_coordinator(hass: HomeAssistant) -> None: +async def test_switch_state_update_from_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test that coordinator data impacts state.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -108,7 +114,8 @@ async def test_switch_state_update_from_coordinator(hass: HomeAssistant) -> None updated_cameras["cameras"][0][KEY_MOTION_DETECTION] = False client.async_get_cameras = AsyncMock(return_value=updated_cameras) - async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Verify the switch turns off. @@ -144,7 +151,9 @@ async def test_switch_has_correct_entities(hass: HomeAssistant) -> None: assert not entity_state -async def test_disabled_switches_can_be_enabled(hass: HomeAssistant) -> None: +async def test_disabled_switches_can_be_enabled( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Verify disabled switches can be enabled.""" client = create_mock_motioneye_client() await setup_mock_motioneye_config_entry(hass, client=client) @@ -174,10 +183,8 @@ async def test_disabled_switches_can_be_enabled(hass: HomeAssistant) -> None: assert not updated_entry.disabled await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) + freezer.tick(timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1)) + async_fire_time_changed(hass) await hass.async_block_till_done() entity_state = hass.states.get(entity_id) diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index a55f71b1d60..617f472ab4e 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -46,7 +46,7 @@ from . import ( setup_mock_motioneye_config_entry, ) -from tests.common import async_capture_events, async_fire_time_changed +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed from tests.typing import ClientSessionGenerator WEB_HOOK_MOTION_DETECTED_QUERY_STRING = ( @@ -469,8 +469,10 @@ async def test_event_media_data( assert "media_content_id" not in events[-1].data # Test: Not a loaded motionEye config entry. + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) wrong_device = device_registry.async_get_or_create( - config_entry_id="wrong_config_id", identifiers={("motioneye", "a_1")} + config_entry_id=other_config_entry.entry_id, identifiers={("motioneye", "a_1")} ) resp = await hass_client.post( URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index e69839e6b16..35fba9e2a0c 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest from homeassistant.components import alarm_control_panel, mqtt +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.mqtt.alarm_control_panel import ( MQTT_ALARM_ATTRIBUTES_BLOCKED, ) @@ -74,6 +75,15 @@ from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient CODE_NUMBER = "1234" CODE_TEXT = "HELLO_CODE" +DEFAULT_FEATURES = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + | AlarmControlPanelEntityFeature.TRIGGER +) + DEFAULT_CONFIG = { mqtt.DOMAIN: { alarm_control_panel.DOMAIN: { @@ -223,6 +233,89 @@ async def test_ignore_update_state_if_unknown_via_state_topic( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "expected_features", "valid"), + [ + ( + DEFAULT_CONFIG, + DEFAULT_FEATURES, + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": []},), + ), + AlarmControlPanelEntityFeature(0), + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["arm_home"]},), + ), + AlarmControlPanelEntityFeature.ARM_HOME, + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["arm_home", "arm_away"]},), + ), + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY, + True, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": "invalid"},), + ), + None, + False, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["invalid"]},), + ), + None, + False, + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, + ({"supported_features": ["arm_home", "invalid"]},), + ), + None, + False, + ), + ], +) +async def test_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_features: AlarmControlPanelEntityFeature | None, + valid: bool, +) -> None: + """Test conditional enablement of supported features.""" + if valid: + await mqtt_mock_entry() + assert ( + hass.states.get("alarm_control_panel.test").attributes["supported_features"] + == expected_features + ) + else: + with pytest.raises(AssertionError): + await mqtt_mock_entry() + + @pytest.mark.parametrize( ("hass_config", "service", "payload"), [ diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index e717c04b317..9e0363b3611 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -70,7 +70,6 @@ from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ENTITY_CLIMATE = "climate.test" - DEFAULT_CONFIG = { mqtt.DOMAIN: { climate.DOMAIN: { @@ -82,7 +81,6 @@ DEFAULT_CONFIG = { "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", "swing_mode_command_topic": "swing-mode-topic", - "aux_command_topic": "aux-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ "eco", @@ -225,7 +223,6 @@ async def test_supported_features( | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY ) @@ -1249,11 +1246,16 @@ async def test_set_preset_mode_pessimistic( assert state.attributes.get("preset_mode") == "home" +# Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC +# and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 +# Support will be removed in HA Core 2024.3 @pytest.mark.parametrize( "hass_config", [ help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"aux_state_topic": "aux-state"},) + climate.DOMAIN, + DEFAULT_CONFIG, + ({"aux_command_topic": "aux-topic", "aux_state_topic": "aux-state"},), ) ], ) @@ -1283,7 +1285,18 @@ async def test_set_aux_pessimistic( assert state.attributes.get("aux_heat") == "off" -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +# Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC +# and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 +# Support will be removed in HA Core 2024.3 +# "aux_command_topic": "aux-topic" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"aux_command_topic": "aux-topic"},) + ) + ], +) async def test_set_aux( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -1303,6 +1316,18 @@ async def test_set_aux( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" + support = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.AUX_HEAT + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY + ) + + assert state.attributes.get("supported_features") == support + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( @@ -1548,6 +1573,10 @@ async def test_get_with_templates( assert state.attributes.get("preset_mode") == "eco" # Aux mode + + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 assert state.attributes.get("aux_heat") == "off" async_fire_mqtt_message(hass, "aux-state", "switchmeon") state = hass.states.get(ENTITY_CLIMATE) @@ -1868,6 +1897,24 @@ async def test_temperature_unit( DEFAULT_MAX_TEMP, 25, ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "initial": 68.9, # 20.5 °C + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ), + UnitOfTemperature.CELSIUS, + 20.5, + DEFAULT_MIN_TEMP, + DEFAULT_MAX_TEMP, + 25, + ), ( help_custom_config( climate.DOMAIN, diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 9d580da073e..9aa88c2d7ba 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -26,10 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.generated.mqtt import MQTT -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 2ebc4a50ef0..f0681a537da 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -1,5 +1,6 @@ """Test config flow.""" -from collections.abc import Generator +from collections.abc import Generator, Iterator +from contextlib import contextmanager from pathlib import Path from random import getrandbits from ssl import SSLError @@ -136,19 +137,22 @@ def mock_process_uploaded_file(tmp_path: Path) -> Generator[MagicMock, None, Non file_id_cert = str(uuid4()) file_id_key = str(uuid4()) - def _mock_process_uploaded_file(hass: HomeAssistant, file_id) -> None: + @contextmanager + def _mock_process_uploaded_file( + hass: HomeAssistant, file_id: str + ) -> Iterator[Path | None]: if file_id == file_id_ca: with open(tmp_path / "ca.crt", "wb") as cafile: cafile.write(b"## mock CA certificate file ##") - return tmp_path / "ca.crt" + yield tmp_path / "ca.crt" elif file_id == file_id_cert: with open(tmp_path / "client.crt", "wb") as certfile: certfile.write(b"## mock client certificate file ##") - return tmp_path / "client.crt" + yield tmp_path / "client.crt" elif file_id == file_id_key: with open(tmp_path / "client.key", "wb") as keyfile: keyfile.write(b"## mock key file ##") - return tmp_path / "client.key" + yield tmp_path / "client.key" else: pytest.fail(f"Unexpected file_id: {file_id}") diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index ddce53bfca0..8485e5578fe 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -1,6 +1,8 @@ """The tests for the MQTT device_tracker platform.""" +from datetime import UTC, datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import device_tracker, mqtt @@ -199,9 +201,10 @@ async def test_duplicate_device_tracker_removal( async def test_device_tracker_discovery_update( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test for a discovery update event.""" + freezer.move_to("2023-08-22 19:15:00+00:00") await mqtt_mock_entry() async_fire_mqtt_message( hass, @@ -213,7 +216,9 @@ async def test_device_tracker_discovery_update( state = hass.states.get("device_tracker.beer") assert state is not None assert state.name == "Beer" + assert state.last_updated == datetime(2023, 8, 22, 19, 15, tzinfo=UTC) + freezer.move_to("2023-08-22 19:16:00+00:00") async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -224,6 +229,21 @@ async def test_device_tracker_discovery_update( state = hass.states.get("device_tracker.beer") assert state is not None assert state.name == "Cider" + assert state.last_updated == datetime(2023, 8, 22, 19, 16, tzinfo=UTC) + + freezer.move_to("2023-08-22 19:20:00+00:00") + async_fire_mqtt_message( + hass, + "homeassistant/device_tracker/bla/config", + '{ "name": "Cider", "state_topic": "test-topic" }', + ) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.beer") + assert state is not None + assert state.name == "Cider" + # Entity was not updated as the state was not changed + assert state.last_updated == datetime(2023, 8, 22, 19, 16, tzinfo=UTC) async def test_cleanup_device_tracker( diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index f51d469bde7..c528687623b 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -168,6 +168,83 @@ async def test_correct_config_discovery( assert ("binary_sensor", "bla") in hass.data["mqtt"].discovery_already_discovered +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_discovery_integration_info( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test logging discovery of new and updated items.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Beer", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.0" } }', + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.beer") + + assert state is not None + assert state.name == "Beer" + + assert ( + "Found new component: binary_sensor bla from external application bla2mqtt, version: 1.0" + in caplog.text + ) + caplog.clear() + + # Send an update and add support url + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Milk", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.1", "url": "https://bla2mqtt.example.com/support" } }', + ) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + + assert state is not None + assert state.name == "Milk" + + assert ( + "Component has already been discovered: binary_sensor bla, sending update from external application bla2mqtt, version: 1.1, support URL: https://bla2mqtt.example.com/support" + in caplog.text + ) + + +@pytest.mark.parametrize( + "config_message", + [ + '{ "name": "Beer", "state_topic": "test-topic", "o": "bla2mqtt" }', + '{ "name": "Beer", "state_topic": "test-topic", "o": 2.0 }', + '{ "name": "Beer", "state_topic": "test-topic", "o": null }', + '{ "name": "Beer", "state_topic": "test-topic", "o": {"sw": "bla2mqtt"} }', + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_discovery_with_invalid_integration_info( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + config_message: str, +) -> None: + """Test sending in correct JSON.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + config_message, + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.beer") + + assert state is None + assert ( + "Unable to parse origin information from discovery message, got" in caplog.text + ) + + @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]) async def test_discover_fan( hass: HomeAssistant, @@ -1266,6 +1343,8 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_WILL_MESSAGE", "CONF_WS_PATH", "CONF_WS_HEADERS", + # Integration info + "CONF_SUPPORT_URL", # Undocumented device configuration "CONF_DEPRECATED_VIA_HUB", "CONF_VIA_DEVICE", diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index bc7b8b43523..abcd6e8f3ee 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -8,10 +8,7 @@ import pytest from homeassistant.components import event, mqtt from homeassistant.components.mqtt.event import MQTT_EVENT_ATTRIBUTES_BLOCKED -from homeassistant.const import ( - STATE_UNKNOWN, - Platform, -) +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -51,9 +48,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.common import ( - async_fire_mqtt_message, -) +from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { @@ -508,9 +503,11 @@ async def test_entity_device_info_with_hub( ) -> None: """Test MQTT event device registry integration.""" await mqtt_mock_entry() + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) registry = dr.async_get(hass) hub = registry.async_get_or_create( - config_entry_id="123", + config_entry_id=other_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, manufacturer="manufacturer", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index c0d7a94de5b..e3a12a2c24e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -41,6 +41,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, + MockEntity, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -417,6 +418,37 @@ async def test_value_template_value(hass: HomeAssistant) -> None: assert template_state_calls.call_count == 1 +async def test_value_template_fails( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the rendering of MQTT value template fails.""" + + # test rendering a value fails + entity = MockEntity(entity_id="sensor.test") + entity.hass = hass + tpl = template.Template("{{ value_json.some_var * 2 }}") + val_tpl = mqtt.MqttValueTemplate(tpl, hass=hass, entity=entity) + with pytest.raises(TypeError): + val_tpl.async_render_with_possible_json_value('{"some_var": null }') + await hass.async_block_till_done() + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' " + "rendering template for entity 'sensor.test', " + "template: '{{ value_json.some_var * 2 }}'" + ) in caplog.text + caplog.clear() + with pytest.raises(TypeError): + val_tpl.async_render_with_possible_json_value( + '{"some_var": null }', default=100 + ) + assert ( + "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' " + "rendering template for entity 'sensor.test', " + "template: '{{ value_json.some_var * 2 }}', default value: 100 and payload: " + '{"some_var": null }' + ) in caplog.text + + async def test_service_call_without_topic_does_not_publish( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py new file mode 100644 index 00000000000..b7130cac3bf --- /dev/null +++ b/tests/components/mqtt/test_lawn_mower.py @@ -0,0 +1,888 @@ +"""The tests for mqtt lawn_mower component.""" +import copy +import json +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import lawn_mower, mqtt +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + LawnMowerEntityFeature, +) +from homeassistant.components.mqtt.lawn_mower import MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant, State + +from .test_common import ( + 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_setup, + 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_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message, mock_restore_cache +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +ATTR_ACTIVITY = "activity" + +DEFAULT_FEATURES = ( + LawnMowerEntityFeature.START_MOWING + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.DOCK +) + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "name": "test", + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + } + } +} + + +@pytest.fixture(autouse=True) +def lawn_mower_platform_only(): + """Only setup the lawn_mower platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LAWN_MOWER]): + yield + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "activity_state_topic": "test/lawn_mower_stat", + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "name": "Test Lawn Mower", + } + } + } + ], +) +async def test_run_lawn_mower_setup_and_state_updates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test that it sets up correctly fetches the given payload.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "mowing") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "mowing" + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "docked") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + + # empty payloads are ignored + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "") + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + + +@pytest.mark.parametrize( + ("hass_config", "expected_features"), + [ + ( + DEFAULT_CONFIG, + DEFAULT_FEATURES, + ), + ( + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "pause_command_topic": "pause-test-topic", + "name": "test", + } + } + }, + LawnMowerEntityFeature.PAUSE, + ), + ( + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "dock_command_topic": "dock-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "name": "test", + } + } + }, + LawnMowerEntityFeature.START_MOWING | LawnMowerEntityFeature.DOCK, + ), + ], +) +async def test_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_features: LawnMowerEntityFeature | None, +) -> None: + """Test conditional enablement of supported features.""" + await mqtt_mock_entry() + assert ( + hass.states.get("lawn_mower.test").attributes["supported_features"] + == expected_features + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "activity_state_topic": "test/lawn_mower_stat", + "name": "Test Lawn Mower", + "activity_value_template": "{{ value_json.val }}", + } + } + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", '{"val":"mowing"}') + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "mowing" + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", '{"val":"paused"}') + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "paused" + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", '{"val": null}') + + await hass.async_block_till_done() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG], +) +async def test_run_lawn_mower_service_optimistic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that service calls work in optimistic mode.""" + + fake_state = State("lawn_mower.test", "docked") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("lawn_mower.test") + assert state.state == "docked" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_START_MOWING, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "start_mowing-test-topic", "start_mowing", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "mowing" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_PAUSE, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "pause-test-topic", "pause", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "paused" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_DOCK, + {ATTR_ENTITY_ID: "lawn_mower.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with("dock-test-topic", "dock", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test") + assert state.state == "docked" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "pause_command_topic": "test/lawn_mower_pause_cmd", + "name": "Test Lawn Mower", + } + } + } + ], +) +async def test_restore_lawn_mower_from_invalid_state( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that restoring the state skips invalid values.""" + fake_state = State("lawn_mower.test_lawn_mower", "unknown") + mock_restore_cache(hass, (fake_state,)) + + await mqtt_mock_entry() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "name": "Test Lawn Mower", + "dock_command_topic": "test/lawn_mower_dock_cmd", + "dock_command_template": '{"action": "{{ value }}"}', + "pause_command_topic": "test/lawn_mower_pause_cmd", + "pause_command_template": '{"action": "{{ value }}"}', + "start_mowing_command_topic": "test/lawn_mower_start_mowing_cmd", + "start_mowing_command_template": '{"action": "{{ value }}"}', + } + } + } + ], +) +async def test_run_lawn_mower_service_optimistic_with_command_templates( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test that service calls work in optimistic mode and with a command_template.""" + fake_state = State("lawn_mower.test_lawn_mower", "docked") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_START_MOWING, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_start_mowing_cmd", '{"action": "start_mowing"}', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "mowing" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_PAUSE, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_pause_cmd", '{"action": "pause"}', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "paused" + + await hass.services.async_call( + lawn_mower.DOMAIN, + SERVICE_DOCK, + {ATTR_ENTITY_ID: "lawn_mower.test_lawn_mower"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "test/lawn_mower_dock_cmd", '{"action": "dock"}', 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("lawn_mower.test_lawn_mower") + assert state.state == "docked" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, lawn_mower.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +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, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + MQTT_LAWN_MOWER_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, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: [ + { + "name": "Test 1", + "activity_state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "activity_state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id action only creates one lawn_mower per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, lawn_mower.DOMAIN) + + +async def test_discovery_removal_lawn_mower( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered lawn_mower.""" + data = json.dumps(DEFAULT_CONFIG[mqtt.DOMAIN][lawn_mower.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry, caplog, lawn_mower.DOMAIN, data + ) + + +async def test_discovery_update_lawn_mower( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered lawn_mower.""" + config1 = { + "name": "Beer", + "activity_state_topic": "test-topic", + "command_topic": "test-topic", + "actions": ["milk", "beer"], + } + config2 = { + "name": "Milk", + "activity_state_topic": "test-topic", + "command_topic": "test-topic", + "actions": ["milk"], + } + + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, lawn_mower.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_lawn_mower( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered lawn_mower.""" + data1 = '{ "name": "Beer", "activity_state_topic": "test-topic", "command_topic": "test-topic", "actions": ["milk", "beer"]}' + with patch( + "homeassistant.components.mqtt.lawn_mower.MqttLawnMower.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + lawn_mower.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "invalid" }' + data2 = '{ "name": "Milk", "activity_state_topic": "test-topic", "pause_command_topic": "test-topic"}' + + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, lawn_mower.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT lawn_mower device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT lawn_mower device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + config = { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "name": "test", + "activity_state_topic": "test-topic", + "availability_topic": "avty-topic", + } + } + } + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, lawn_mower.DOMAIN, config, ["avty-topic", "test-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, lawn_mower.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + ("service", "command_payload", "state_payload", "state_topic", "command_topic"), + [ + ( + SERVICE_START_MOWING, + "start_mowing", + "mowing", + "test/lawn_mower_stat", + "start_mowing-test-topic", + ), + ( + SERVICE_PAUSE, + "pause", + "paused", + "test/lawn_mower_stat", + "pause-test-topic", + ), + ( + SERVICE_DOCK, + "dock", + "docked", + "test/lawn_mower_stat", + "dock-test-topic", + ), + ], +) +async def test_entity_debug_info_message( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + command_payload: str, + state_payload: str, + state_topic: str, + command_topic: str, +) -> None: + """Test MQTT debug info.""" + config = { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "activity_state_topic": "test/lawn_mower_stat", + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "name": "test", + } + } + } + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + lawn_mower.DOMAIN, + config, + service=service, + command_payload=command_payload, + state_payload=state_payload, + state_topic=state_topic, + command_topic=command_topic, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lawn_mower.DOMAIN: { + "dock_command_topic": "dock-test-topic", + "pause_command_topic": "pause-test-topic", + "start_mowing_command_topic": "start_mowing-test-topic", + "activity_state_topic": "test/lawn_mower_stat", + "name": "Test Lawn Mower", + } + } + } + ], +) +async def test_mqtt_payload_not_a_valid_activity_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a valid activity.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test/lawn_mower_stat", "painting") + + await hass.async_block_till_done() + + assert ( + "Invalid activity for lawn_mower.test_lawn_mower: 'painting' (valid activies: ['error', 'paused', 'mowing', 'docked'])" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + SERVICE_START_MOWING, + "start_mowing_command_topic", + {}, + "start_mowing", + "start_mowing_command_template", + ), + ( + SERVICE_PAUSE, + "pause_command_topic", + {}, + "pause", + "pause_command_template", + ), + ( + SERVICE_DOCK, + "dock_command_topic", + {}, + "dock", + "dock_command_template", + ), + ], +) +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 = lawn_mower.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = lawn_mower.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("activity_state_topic", "paused", None, "paused"), + ("activity_state_topic", "docked", None, "docked"), + ("activity_state_topic", "mowing", None, "mowing"), + ], +) +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.""" + config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][lawn_mower.DOMAIN]) + config["actions"] = ["milk", "beer"] + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + lawn_mower.DOMAIN, + config, + topic, + value, + attribute, + attribute_value, + ) + + +@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 = lawn_mower.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = lawn_mower.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +async def test_persistent_state_after_reconfig( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test of the state is persistent after reconfiguring the lawn_mower activity.""" + await mqtt_mock_entry() + discovery_data = '{ "name": "Garden", "activity_state_topic": "test-topic", "command_topic": "test-topic"}' + await help_test_discovery_setup(hass, LAWN_MOWER_DOMAIN, discovery_data, "garden") + + # assign an initial state + async_fire_mqtt_message(hass, "test-topic", "docked") + state = hass.states.get("lawn_mower.garden") + assert state.state == "docked" + + # change the config + discovery_data = '{ "name": "Garden", "activity_state_topic": "test-topic2", "command_topic": "test-topic"}' + await help_test_discovery_setup(hass, LAWN_MOWER_DOMAIN, discovery_data, "garden") + + # assert the state persistent + state = hass.states.get("lawn_mower.garden") + assert state.state == "docked" diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 18269eb6970..0647721b4d0 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -12,10 +12,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import ( - device_registry as dr, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, issue_registry as ir from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 30eb0fd1939..043c8d539b6 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -66,6 +66,7 @@ from .test_common import ( ) from tests.common import ( + MockConfigEntry, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache_with_extra_data, @@ -1123,9 +1124,11 @@ async def test_entity_device_info_with_hub( ) -> None: """Test MQTT sensor device registry integration.""" await mqtt_mock_entry() + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) registry = dr.async_get(hass) hub = registry.async_get_or_create( - config_entry_id="123", + config_entry_id=other_config_entry.entry_id, connections=set(), identifiers={("mqtt", "hub-id")}, manufacturer="manufacturer", diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 96577bd3fa4..e93a5e376bb 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -37,7 +37,7 @@ def mock_temp_dir(): async def test_async_create_certificate_temp_files( hass: HomeAssistant, mock_temp_dir, option, content, file_created ) -> None: - """Test creating and reading certificate files.""" + """Test creating and reading and recovery certificate files.""" config = {option: content} await mqtt.util.async_create_certificate_temp_files(hass, config) @@ -47,6 +47,22 @@ async def test_async_create_certificate_temp_files( mqtt.util.migrate_certificate_file_to_content(file_path or content) == content ) + # Make sure certificate temp files are recovered + if file_path: + # Overwrite content of file (except for auto option) + file = open(file_path, "wb") + file.write(b"invalid") + file.close() + + await mqtt.util.async_create_certificate_temp_files(hass, config) + file_path2 = mqtt.util.get_file_path(option) + assert bool(file_path2) is file_created + assert ( + mqtt.util.migrate_certificate_file_to_content(file_path2 or content) == content + ) + + assert file_path == file_path2 + async def test_reading_non_exitisting_certificate_file() -> None: """Test reading a non existing certificate file.""" diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 9e082dc1b05..56c5bedaf0d 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -244,8 +244,6 @@ async def test_camera_stream( stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" - assert await async_get_image(hass) == IMAGE_BYTES_FROM_STREAM - async def test_camera_ws_stream( hass: HomeAssistant, @@ -280,8 +278,6 @@ async def test_camera_ws_stream( assert msg["success"] assert msg["result"]["url"] == "http://home.assistant/playlist.m3u8" - assert await async_get_image(hass) == IMAGE_BYTES_FROM_STREAM - async def test_camera_ws_stream_failure( hass: HomeAssistant, @@ -746,8 +742,6 @@ async def test_camera_multiple_streams( stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" - assert await async_get_image(hass) == IMAGE_BYTES_FROM_STREAM - # WebRTC stream client = await hass_ws_client(hass) await client.send_json( diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index 037894b43f5..c920eb5717d 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -758,6 +758,75 @@ async def test_thermostat_set_temperature_hvac_mode( } +@pytest.mark.parametrize( + ("setpoint", "target_low", "target_high", "expected_params"), + [ + ( + { + "heatCelsius": 19.0, + "coolCelsius": 25.0, + }, + 19.0, + 20.0, + # Cool is accepted and lowers heat by the min range + {"heatCelsius": 18.33333, "coolCelsius": 20.0}, + ), + ( + { + "heatCelsius": 19.0, + "coolCelsius": 25.0, + }, + 24.0, + 25.0, + # Cool is accepted and lowers heat by the min range + {"heatCelsius": 24.0, "coolCelsius": 25.66667}, + ), + ], +) +async def test_thermostat_set_temperature_range_too_close( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, + setpoint: dict[str, Any], + target_low: float, + target_high: float, + expected_params: dict[str, Any], +) -> None: + """Test setting an HVAC temperature range that is too small of a range.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEATCOOL", + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": setpoint, + }, + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVACMode.HEAT_COOL + + # Move the target temp to be in too small of a range + await common.async_set_temperature( + hass, + target_temp_low=target_low, + target_temp_high=target_high, + ) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange", + "params": expected_params, + } + + async def test_thermostat_set_heat_cool( hass: HomeAssistant, setup_platform: PlatformSetup, diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index a35b10afa9c..852075c6527 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -17,10 +17,7 @@ from homeassistant.util.dt import utcnow from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup -from tests.common import ( - async_get_device_automations, - async_mock_service, -) +from tests.common import async_get_device_automations, async_mock_service DEVICE_NAME = "My Camera" DATA_MESSAGE = {"message": "service-called"} diff --git a/tests/components/netatmo/fixtures/getpublicdata.json b/tests/components/netatmo/fixtures/getpublicdata.json index cf2ec3c66cb..622e7f962f1 100644 --- a/tests/components/netatmo/fixtures/getpublicdata.json +++ b/tests/components/netatmo/fixtures/getpublicdata.json @@ -91,7 +91,7 @@ }, "70:ee:50:27:25:b0": { "res": { - "1560247907": [1012.8] + "1560247907": [1012.9] }, "type": ["pressure"] }, diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index f4d24e87b91..c6146dca339 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -415,7 +415,7 @@ async def test_setup_component_invalid_token(hass: HomeAssistant, config_entry) headers={}, real_url="http://example.com", ), - code=400, + status=400, history=(), ) diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 5c04f0d2fc7..00cec6f8aa0 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -46,7 +46,7 @@ async def test_public_weather_sensor( assert hass.states.get(f"{prefix}temperature").state == "22.7" assert hass.states.get(f"{prefix}humidity").state == "63.2" - assert hass.states.get(f"{prefix}pressure").state == "1010.3" + assert hass.states.get(f"{prefix}pressure").state == "1010.4" entities_before_change = len(hass.states.async_all()) diff --git a/tests/components/nexia/fixtures/set_fan_speed_2293892.json b/tests/components/nexia/fixtures/set_fan_speed_2293892.json new file mode 100644 index 00000000000..bad0fccb2ad --- /dev/null +++ b/tests/components/nexia/fixtures/set_fan_speed_2293892.json @@ -0,0 +1,3086 @@ +{ + "success": true, + "error": null, + "result": { + "id": 2293892, + "name": "Master Suite", + "name_editable": true, + "features": [ + { + "name": "advanced_info", + "items": [ + { + "type": "label_value", + "label": "Model", + "value": "XL1050" + }, + { + "type": "label_value", + "label": "AUID", + "value": "0281B02C" + }, + { + "type": "label_value", + "label": "Firmware Build Number", + "value": "1581321824" + }, + { + "type": "label_value", + "label": "Firmware Build Date", + "value": "2020-02-10 08:03:44 UTC" + }, + { + "type": "label_value", + "label": "Firmware Version", + "value": "5.9.1" + }, + { + "type": "label_value", + "label": "Zoning Enabled", + "value": "yes" + } + ] + }, + { + "name": "thermostat", + "temperature": 73, + "status": "Cooling", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": {}, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99 + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "group", + "members": [ + { + "type": "xxl_zone", + "id": 83394133, + "name": "Bath Closet", + "current_zone_mode": "AUTO", + "temperature": 73, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Relieving Air", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Relieving Air", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-73"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 73, + "status": "Relieving Air", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133" + } + } + }, + { + "type": "xxl_zone", + "id": 83394130, + "name": "Master", + "current_zone_mode": "AUTO", + "temperature": 74, + "setpoints": { + "heat": 63, + "cool": 71 + }, + "operating_state": "Damper Open", + "heating_setpoint": 63, + "cooling_setpoint": 71, + "zone_status": "Damper Open", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-74"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 74, + "status": "Damper Open", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 71, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130" + } + } + }, + { + "type": "xxl_zone", + "id": 83394136, + "name": "Nick Office", + "current_zone_mode": "AUTO", + "temperature": 73, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Relieving Air", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Relieving Air", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-73"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 73, + "status": "Relieving Air", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136" + } + } + }, + { + "type": "xxl_zone", + "id": 83394127, + "name": "Snooze Room", + "current_zone_mode": "AUTO", + "temperature": 72, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Damper Closed", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Damper Closed", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-72"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 72, + "status": "Damper Closed", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127" + } + } + }, + { + "type": "xxl_zone", + "id": 83394139, + "name": "Safe Room", + "current_zone_mode": "AUTO", + "temperature": 74, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Damper Closed", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Damper Closed", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-74"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 74, + "status": "Damper Closed", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139" + } + } + } + ] + }, + { + "name": "thermostat_fan_mode", + "label": "Fan Mode", + "options": [ + { + "id": "thermostat_fan_mode", + "label": "Fan Mode", + "value": "thermostat_fan_mode", + "header": true + }, + { + "value": "auto", + "label": "Auto" + }, + { + "value": "on", + "label": "On" + }, + { + "value": "circulate", + "label": "Circulate" + } + ], + "value": "auto", + "display_value": "Auto", + "status_icon": { + "name": "thermostat_fan_on", + "modifiers": [] + }, + "actions": { + "update_thermostat_fan_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode" + } + } + }, + { + "name": "thermostat_compressor_speed", + "compressor_speed": 0.69 + }, + { + "name": "runtime_history", + "actions": { + "get_runtime_history": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/runtime_history/2293892?report_type=daily" + }, + "get_monthly_runtime_history": { + "method": "GET", + "href": "https://www.mynexia.com/mobile/runtime_history/2293892?report_type=monthly" + } + } + } + ], + "icon": [ + { + "name": "thermostat", + "modifiers": ["temperature-73"] + }, + { + "name": "thermostat", + "modifiers": ["temperature-74"] + }, + { + "name": "thermostat", + "modifiers": ["temperature-73"] + }, + { + "name": "thermostat", + "modifiers": ["temperature-72"] + }, + { + "name": "thermostat", + "modifiers": ["temperature-74"] + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892" + }, + "nexia:history": { + "href": "https://www.mynexia.com/mobile/houses/123456/events?device_id=2293892" + }, + "filter_events": { + "href": "https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0" + }, + "pending_request": { + "polling_path": "https://www.mynexia.com/backstage/announcements/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e" + } + }, + "last_updated_at": "2020-03-11T15:15:53.000-05:00", + "settings": [ + { + "type": "fan_mode", + "title": "Fan Mode", + "current_value": "auto", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "on", + "label": "On" + }, + { + "value": "circulate", + "label": "Circulate" + } + ], + "labels": ["Auto", "On", "Circulate"], + "values": ["auto", "on", "circulate"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode" + } + } + }, + { + "type": "fan_speed", + "title": "Fan Speed", + "current_value": 0.5, + "options": [ + { + "value": 0.35, + "label": "35%" + }, + { + "value": 0.4, + "label": "40%" + }, + { + "value": 0.45, + "label": "45%" + }, + { + "value": 0.5, + "label": "50%" + }, + { + "value": 0.55, + "label": "55%" + }, + { + "value": 0.6, + "label": "60%" + }, + { + "value": 0.65, + "label": "65%" + }, + { + "value": 0.7, + "label": "70%" + }, + { + "value": 0.75, + "label": "75%" + }, + { + "value": 0.8, + "label": "80%" + }, + { + "value": 0.85, + "label": "85%" + }, + { + "value": 0.9, + "label": "90%" + }, + { + "value": 0.95, + "label": "95%" + }, + { + "value": 1.0, + "label": "100%" + } + ], + "labels": [ + "35%", + "40%", + "45%", + "50%", + "55%", + "60%", + "65%", + "70%", + "75%", + "80%", + "85%", + "90%", + "95%", + "100%" + ], + "values": [ + 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, + 0.95, 1.0 + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_speed" + } + } + }, + { + "type": "fan_circulation_time", + "title": "Fan Circulation Time", + "current_value": 30, + "options": [ + { + "value": 10, + "label": "10 minutes" + }, + { + "value": 15, + "label": "15 minutes" + }, + { + "value": 20, + "label": "20 minutes" + }, + { + "value": 25, + "label": "25 minutes" + }, + { + "value": 30, + "label": "30 minutes" + }, + { + "value": 35, + "label": "35 minutes" + }, + { + "value": 40, + "label": "40 minutes" + }, + { + "value": 45, + "label": "45 minutes" + }, + { + "value": 50, + "label": "50 minutes" + }, + { + "value": 55, + "label": "55 minutes" + } + ], + "labels": [ + "10 minutes", + "15 minutes", + "20 minutes", + "25 minutes", + "30 minutes", + "35 minutes", + "40 minutes", + "45 minutes", + "50 minutes", + "55 minutes" + ], + "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_circulation_time" + } + } + }, + { + "type": "air_cleaner_mode", + "title": "Air Cleaner Mode", + "current_value": "auto", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "quick", + "label": "Quick" + }, + { + "value": "allergy", + "label": "Allergy" + } + ], + "labels": ["Auto", "Quick", "Allergy"], + "values": ["auto", "quick", "allergy"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/air_cleaner_mode" + } + } + }, + { + "type": "dehumidify", + "title": "Cooling Dehumidify Set Point", + "current_value": 0.45, + "options": [ + { + "value": 0.35, + "label": "35%" + }, + { + "value": 0.4, + "label": "40%" + }, + { + "value": 0.45, + "label": "45%" + }, + { + "value": 0.5, + "label": "50%" + }, + { + "value": 0.55, + "label": "55%" + }, + { + "value": 0.6, + "label": "60%" + }, + { + "value": 0.65, + "label": "65%" + } + ], + "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], + "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/dehumidify" + } + } + }, + { + "type": "scale", + "title": "Temperature Scale", + "current_value": "f", + "options": [ + { + "value": "f", + "label": "F" + }, + { + "value": "c", + "label": "C" + } + ], + "labels": ["F", "C"], + "values": ["f", "c"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892/scale" + } + } + } + ], + "status_secondary": null, + "status_tertiary": null, + "type": "xxl_thermostat", + "has_outdoor_temperature": true, + "outdoor_temperature": "87", + "has_indoor_humidity": true, + "connected": true, + "indoor_humidity": "52", + "system_status": "Cooling", + "delta": 3, + "zones": [ + { + "type": "xxl_zone", + "id": 83394133, + "name": "Bath Closet", + "current_zone_mode": "AUTO", + "temperature": 73, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Relieving Air", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Relieving Air", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-73"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 73, + "status": "Relieving Air", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394133" + } + } + }, + { + "type": "xxl_zone", + "id": 83394130, + "name": "Master", + "current_zone_mode": "AUTO", + "temperature": 74, + "setpoints": { + "heat": 63, + "cool": 71 + }, + "operating_state": "Damper Open", + "heating_setpoint": 63, + "cooling_setpoint": 71, + "zone_status": "Damper Open", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-74"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 74, + "status": "Damper Open", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 71, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394130" + } + } + }, + { + "type": "xxl_zone", + "id": 83394136, + "name": "Nick Office", + "current_zone_mode": "AUTO", + "temperature": 73, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Relieving Air", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Relieving Air", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-73"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 73, + "status": "Relieving Air", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394136" + } + } + }, + { + "type": "xxl_zone", + "id": 83394127, + "name": "Snooze Room", + "current_zone_mode": "AUTO", + "temperature": 72, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Damper Closed", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Damper Closed", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-72"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 72, + "status": "Damper Closed", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394127" + } + } + }, + { + "type": "xxl_zone", + "id": 83394139, + "name": "Safe Room", + "current_zone_mode": "AUTO", + "temperature": 74, + "setpoints": { + "heat": 63, + "cool": 79 + }, + "operating_state": "Damper Closed", + "heating_setpoint": 63, + "cooling_setpoint": 79, + "zone_status": "Damper Closed", + "settings": [ + { + "type": "preset_selected", + "title": "Preset", + "current_value": 0, + "options": [ + { + "value": 0, + "label": "None" + }, + { + "value": 1, + "label": "Home" + }, + { + "value": 2, + "label": "Away" + }, + { + "value": 3, + "label": "Sleep" + } + ], + "labels": ["None", "Home", "Away", "Sleep"], + "values": [0, 1, 2, 3], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected" + } + } + }, + { + "type": "zone_mode", + "title": "Zone Mode", + "current_value": "AUTO", + "options": [ + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "labels": ["Auto", "Cooling", "Heating", "Off"], + "values": ["AUTO", "COOL", "HEAT", "OFF"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode" + } + } + }, + { + "type": "run_mode", + "title": "Run Mode", + "current_value": "permanent_hold", + "options": [ + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "labels": ["Permanent Hold", "Run Schedule"], + "values": ["permanent_hold", "run_schedule"], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode" + } + } + }, + { + "type": "scheduling_enabled", + "title": "Scheduling", + "current_value": true, + "options": [ + { + "value": true, + "label": "ON" + }, + { + "value": false, + "label": "OFF" + } + ], + "labels": ["ON", "OFF"], + "values": [true, false], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled" + } + } + } + ], + "icon": { + "name": "thermostat", + "modifiers": ["temperature-74"] + }, + "features": [ + { + "name": "thermostat", + "temperature": 74, + "status": "Damper Closed", + "status_icon": { + "name": "cooling", + "modifiers": [] + }, + "actions": { + "set_heat_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints" + }, + "set_cool_setpoint": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints" + } + }, + "setpoint_delta": 3, + "scale": "f", + "setpoint_increment": 1.0, + "setpoint_heat_min": 55, + "setpoint_heat_max": 90, + "setpoint_cool_min": 60, + "setpoint_cool_max": 99, + "setpoint_heat": 63, + "setpoint_cool": 79, + "system_status": "Cooling" + }, + { + "name": "connection", + "signal_strength": "unknown", + "is_connected": true + }, + { + "name": "thermostat_mode", + "label": "Zone Mode", + "value": "AUTO", + "display_value": "Auto", + "options": [ + { + "id": "thermostat_mode", + "label": "Zone Mode", + "value": "thermostat_mode", + "header": true + }, + { + "value": "AUTO", + "label": "Auto" + }, + { + "value": "COOL", + "label": "Cooling" + }, + { + "value": "HEAT", + "label": "Heating" + }, + { + "value": "OFF", + "label": "Off" + } + ], + "actions": { + "update_thermostat_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode" + } + } + }, + { + "name": "thermostat_run_mode", + "label": "Run Mode", + "options": [ + { + "id": "thermostat_run_mode", + "label": "Run Mode", + "value": "thermostat_run_mode", + "header": true + }, + { + "id": "info_text", + "label": "Follow or override the schedule.", + "value": "info_text", + "info": true + }, + { + "value": "permanent_hold", + "label": "Permanent Hold" + }, + { + "value": "run_schedule", + "label": "Run Schedule" + } + ], + "value": "permanent_hold", + "display_value": "Hold", + "actions": { + "update_thermostat_run_mode": { + "method": "POST", + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode" + } + } + }, + { + "name": "schedule", + "enabled": true, + "max_period_name_length": 10, + "setpoint_increment": 1, + "collection_url": "https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139\u0026house_id=123456", + "actions": { + "get_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139", + "method": "POST" + }, + "set_active_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139", + "method": "POST" + }, + "get_default_schedule": { + "href": "https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139", + "method": "GET" + }, + "enable_scheduling": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled", + "method": "POST", + "data": { + "value": true + } + } + }, + "can_add_remove_periods": true, + "max_periods_per_day": 4 + } + ], + "_links": { + "self": { + "href": "https://www.mynexia.com/mobile/xxl_zones/83394139" + } + } + } + ] + } +} diff --git a/tests/components/nexia/test_binary_sensor.py b/tests/components/nexia/test_binary_sensor.py index 78753383b03..f59e968d634 100644 --- a/tests/components/nexia/test_binary_sensor.py +++ b/tests/components/nexia/test_binary_sensor.py @@ -14,7 +14,7 @@ async def test_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_ON expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Blower Active", + "friendly_name": "Master Suite Blower active", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -26,7 +26,7 @@ async def test_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_OFF expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Downstairs East Wing Blower Active", + "friendly_name": "Downstairs East Wing Blower active", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py index 5409181f00e..f920592f8a6 100644 --- a/tests/components/nexia/test_init.py +++ b/tests/components/nexia/test_init.py @@ -53,7 +53,7 @@ async def test_device_remove_devices( is False ) - entity = registry.entities["sensor.master_suite_relative_humidity"] + entity = registry.entities["sensor.master_suite_humidity"] live_thermostat_device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/nexia/test_number.py b/tests/components/nexia/test_number.py new file mode 100644 index 00000000000..7f4c5f92ab6 --- /dev/null +++ b/tests/components/nexia/test_number.py @@ -0,0 +1,62 @@ +"""The number entity tests for the nexia platform.""" + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_create_fan_speed_number_entities(hass: HomeAssistant) -> None: + """Test creation of fan speed number entities.""" + + await async_init_integration(hass) + + state = hass.states.get("number.master_suite_fan_speed") + assert state.state == "35.0" + expected_attributes = { + "attribution": "Data provided by Trane Technologies", + "friendly_name": "Master Suite Fan speed", + "min": 35, + "max": 100, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) + + state = hass.states.get("number.downstairs_east_wing_fan_speed") + assert state.state == "35.0" + expected_attributes = { + "attribution": "Data provided by Trane Technologies", + "friendly_name": "Downstairs East Wing Fan speed", + "min": 35, + "max": 100, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) + + +async def test_set_fan_speed(hass: HomeAssistant) -> None: + """Test setting fan speed.""" + + await async_init_integration(hass) + + state_before = hass.states.get("number.master_suite_fan_speed") + assert state_before.state == "35.0" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: 50}, + blocking=True, + target={"entity_id": "number.master_suite_fan_speed"}, + ) + state = hass.states.get("number.master_suite_fan_speed") + assert state.state == "50.0" diff --git a/tests/components/nexia/test_sensor.py b/tests/components/nexia/test_sensor.py index 4d693261a9d..23a92af71c8 100644 --- a/tests/components/nexia/test_sensor.py +++ b/tests/components/nexia/test_sensor.py @@ -29,7 +29,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: assert state.state == "Permanent Hold" expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Nick Office Zone Setpoint Status", + "friendly_name": "Nick Office Zone setpoint status", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -42,7 +42,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Nick Office Zone Status", + "friendly_name": "Nick Office Zone status", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -55,7 +55,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Air Cleaner Mode", + "friendly_name": "Master Suite Air cleaner mode", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -68,7 +68,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Current Compressor Speed", + "friendly_name": "Master Suite Current compressor speed", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -83,7 +83,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", "device_class": "temperature", - "friendly_name": "Master Suite Outdoor Temperature", + "friendly_name": "Master Suite Outdoor temperature", "unit_of_measurement": UnitOfTemperature.CELSIUS, } # Only test for a subset of attributes in case @@ -92,13 +92,13 @@ async def test_create_sensors(hass: HomeAssistant) -> None: state.attributes[key] == expected_attributes[key] for key in expected_attributes ) - state = hass.states.get("sensor.master_suite_relative_humidity") + state = hass.states.get("sensor.master_suite_humidity") assert state.state == "52.0" expected_attributes = { "attribution": "Data provided by Trane Technologies", "device_class": "humidity", - "friendly_name": "Master Suite Relative Humidity", + "friendly_name": "Master Suite Humidity", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -112,7 +112,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Requested Compressor Speed", + "friendly_name": "Master Suite Requested compressor speed", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -126,7 +126,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite System Status", + "friendly_name": "Master Suite System status", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py index 318a317fae4..d47e3fd3d6a 100644 --- a/tests/components/nexia/util.py +++ b/tests/components/nexia/util.py @@ -22,6 +22,7 @@ async def async_init_integration( house_fixture = "nexia/mobile_houses_123456.json" session_fixture = "nexia/session_123456.json" sign_in_fixture = "nexia/sign_in.json" + set_fan_speed_fixture = "nexia/set_fan_speed_2293892.json" with mock_aiohttp_client() as mock_session, patch( "nexia.home.load_or_create_uuid", return_value=uuid.uuid4() ): @@ -46,6 +47,10 @@ async def async_init_integration( nexia.API_MOBILE_ACCOUNTS_SIGN_IN_URL, text=load_fixture(sign_in_fixture), ) + mock_session.post( + "https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_speed", + text=load_fixture(set_fan_speed_fixture), + ) entry = MockConfigEntry( domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} ) diff --git a/tests/components/nextdns/__init__.py b/tests/components/nextdns/__init__.py index 3ef09cae6c8..a175bffbb75 100644 --- a/tests/components/nextdns/__init__.py +++ b/tests/components/nextdns/__init__.py @@ -119,6 +119,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: title="Fake Profile", unique_id="xyz12", data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, + entry_id="d9aa37407ddac7b964a99e86312288d6", ) with patch( diff --git a/tests/components/nextdns/fixtures/settings.json b/tests/components/nextdns/fixtures/settings.json deleted file mode 100644 index 57ed97cfb19..00000000000 --- a/tests/components/nextdns/fixtures/settings.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "block_page": false, - "cache_boost": true, - "cname_flattening": true, - "anonymized_ecs": true, - "logs": true, - "logs_location": "ch", - "logs_retention": 720, - "web3": true, - "allow_affiliate": true, - "block_disguised_trackers": true, - "ai_threat_detection": true, - "block_csam": true, - "block_ddns": true, - "block_nrd": true, - "block_parked_domains": true, - "cryptojacking_protection": true, - "dga_protection": true, - "dns_rebinding_protection": true, - "google_safe_browsing": false, - "idn_homograph_attacks_protection": true, - "threat_intelligence_feeds": true, - "typosquatting_protection": true, - "block_bypass_methods": true, - "safesearch": false, - "youtube_restricted_mode": false, - "block_9gag": true, - "block_amazon": true, - "block_bereal": true, - "block_blizzard": true, - "block_chatgpt": true, - "block_dailymotion": true, - "block_discord": true, - "block_disneyplus": true, - "block_ebay": true, - "block_facebook": true, - "block_fortnite": true, - "block_google_chat": true, - "block_hbomax": true, - "block_hulu": true, - "block_imgur": true, - "block_instagram": true, - "block_leagueoflegends": true, - "block_mastodon": true, - "block_messenger": true, - "block_minecraft": true, - "block_netflix": true, - "block_pinterest": true, - "block_playstation_network": true, - "block_primevideo": true, - "block_reddit": true, - "block_roblox": true, - "block_signal": true, - "block_skype": true, - "block_snapchat": true, - "block_spotify": true, - "block_steam": true, - "block_telegram": true, - "block_tiktok": true, - "block_tinder": true, - "block_tumblr": true, - "block_twitch": true, - "block_twitter": true, - "block_vimeo": true, - "block_vk": true, - "block_whatsapp": true, - "block_xboxlive": true, - "block_youtube": true, - "block_zoom": true, - "block_dating": true, - "block_gambling": true, - "block_online_gaming": true, - "block_piracy": true, - "block_porn": true, - "block_social_networks": true, - "block_video_streaming": true -} diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..071d14f183b --- /dev/null +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -0,0 +1,136 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'profile_id': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'nextdns', + 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Fake Profile', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'dnssec_coordinator_data': dict({ + 'not_validated_queries': 25, + 'validated_queries': 75, + 'validated_queries_ratio': 75.0, + }), + 'encryption_coordinator_data': dict({ + 'encrypted_queries': 60, + 'encrypted_queries_ratio': 60.0, + 'unencrypted_queries': 40, + }), + 'ip_versions_coordinator_data': dict({ + 'ipv4_queries': 90, + 'ipv6_queries': 10, + 'ipv6_queries_ratio': 10.0, + }), + 'protocols_coordinator_data': dict({ + 'doh3_queries': 15, + 'doh3_queries_ratio': 13.0, + 'doh_queries': 20, + 'doh_queries_ratio': 17.4, + 'doq_queries': 10, + 'doq_queries_ratio': 8.7, + 'dot_queries': 30, + 'dot_queries_ratio': 26.1, + 'tcp_queries': 0, + 'tcp_queries_ratio': 0.0, + 'udp_queries': 40, + 'udp_queries_ratio': 34.8, + }), + 'settings_coordinator_data': dict({ + 'ai_threat_detection': True, + 'allow_affiliate': True, + 'anonymized_ecs': True, + 'block_9gag': True, + 'block_amazon': True, + 'block_bereal': True, + 'block_blizzard': True, + 'block_bypass_methods': True, + 'block_chatgpt': True, + 'block_csam': True, + 'block_dailymotion': True, + 'block_dating': True, + 'block_ddns': True, + 'block_discord': True, + 'block_disguised_trackers': True, + 'block_disneyplus': True, + 'block_ebay': True, + 'block_facebook': True, + 'block_fortnite': True, + 'block_gambling': True, + 'block_google_chat': True, + 'block_hbomax': True, + 'block_hulu': True, + 'block_imgur': True, + 'block_instagram': True, + 'block_leagueoflegends': True, + 'block_mastodon': True, + 'block_messenger': True, + 'block_minecraft': True, + 'block_netflix': True, + 'block_nrd': True, + 'block_online_gaming': True, + 'block_page': False, + 'block_parked_domains': True, + 'block_pinterest': True, + 'block_piracy': True, + 'block_playstation_network': True, + 'block_porn': True, + 'block_primevideo': True, + 'block_reddit': True, + 'block_roblox': True, + 'block_signal': True, + 'block_skype': True, + 'block_snapchat': True, + 'block_social_networks': True, + 'block_spotify': True, + 'block_steam': True, + 'block_telegram': True, + 'block_tiktok': True, + 'block_tinder': True, + 'block_tumblr': True, + 'block_twitch': True, + 'block_twitter': True, + 'block_video_streaming': True, + 'block_vimeo': True, + 'block_vk': True, + 'block_whatsapp': True, + 'block_xboxlive': True, + 'block_youtube': True, + 'block_zoom': True, + 'cache_boost': True, + 'cname_flattening': True, + 'cryptojacking_protection': True, + 'dga_protection': True, + 'dns_rebinding_protection': True, + 'google_safe_browsing': False, + 'idn_homograph_attacks_protection': True, + 'logs': True, + 'logs_location': 'ch', + 'logs_retention': 720, + 'safesearch': False, + 'threat_intelligence_feeds': True, + 'typosquatting_protection': True, + 'web3': True, + 'youtube_restricted_mode': False, + }), + 'status_coordinator_data': dict({ + 'all_queries': 100, + 'allowed_queries': 30, + 'blocked_queries': 20, + 'blocked_queries_ratio': 20.0, + 'default_queries': 40, + 'relayed_queries': 10, + }), + }) +# --- diff --git a/tests/components/nextdns/test_diagnostics.py b/tests/components/nextdns/test_diagnostics.py index 1da52b26e3f..7652bc4f03e 100644 --- a/tests/components/nextdns/test_diagnostics.py +++ b/tests/components/nextdns/test_diagnostics.py @@ -1,74 +1,21 @@ """Test NextDNS diagnostics.""" -import json -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - settings = json.loads(load_fixture("settings.json", "nextdns")) - entry = await init_integration(hass) - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - assert result["config_entry"] == { - "entry_id": entry.entry_id, - "version": 1, - "domain": "nextdns", - "title": "Fake Profile", - "data": {"profile_id": REDACTED, "api_key": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - } - assert result["dnssec_coordinator_data"] == { - "not_validated_queries": 25, - "validated_queries": 75, - "validated_queries_ratio": 75.0, - } - assert result["encryption_coordinator_data"] == { - "encrypted_queries": 60, - "unencrypted_queries": 40, - "encrypted_queries_ratio": 60.0, - } - assert result["ip_versions_coordinator_data"] == { - "ipv6_queries": 10, - "ipv4_queries": 90, - "ipv6_queries_ratio": 10.0, - } - assert result["protocols_coordinator_data"] == { - "doh_queries": 20, - "doh3_queries": 15, - "doq_queries": 10, - "dot_queries": 30, - "tcp_queries": 0, - "udp_queries": 40, - "doh_queries_ratio": 17.4, - "doh3_queries_ratio": 13.0, - "doq_queries_ratio": 8.7, - "dot_queries_ratio": 26.1, - "tcp_queries_ratio": 0.0, - "udp_queries_ratio": 34.8, - } - assert result["settings_coordinator_data"] == settings - assert result["status_coordinator_data"] == { - "all_queries": 100, - "allowed_queries": 30, - "blocked_queries": 20, - "default_queries": 40, - "relayed_queries": 10, - "blocked_queries_ratio": 20.0, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index c6fd5bdd830..8532415c6b1 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import patch from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.nina.const import ( + ATTR_AFFECTED_AREAS, ATTR_DESCRIPTION, ATTR_EXPIRES, ATTR_HEADLINE, @@ -38,6 +39,13 @@ ENTRY_DATA_NO_CORONA: dict[str, Any] = { "regions": {"083350000000": "Aach, Stadt"}, } +ENTRY_DATA_NO_AREA: dict[str, Any] = { + "slots": 5, + "corona_filter": False, + "area_filter": ".*nagold.*", + "regions": {"083350000000": "Aach, Stadt"}, +} + async def test_sensors(hass: HomeAssistant) -> None: """Test the creation and values of the NINA sensors.""" @@ -70,6 +78,10 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w1.attributes.get(ATTR_SENDER) == "Deutscher Wetterdienst" assert state_w1.attributes.get(ATTR_SEVERITY) == "Minor" assert state_w1.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "" + assert ( + state_w1.attributes.get(ATTR_AFFECTED_AREAS) + == "Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere." + ) assert state_w1.attributes.get(ATTR_ID) == "mow.DE-NW-BN-SE030-20201014-30-000" assert state_w1.attributes.get(ATTR_SENT) == "2021-10-11T05:20:00+01:00" assert state_w1.attributes.get(ATTR_START) == "2021-11-01T05:20:00+01:00" @@ -87,6 +99,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w2.attributes.get(ATTR_SENDER) is None assert state_w2.attributes.get(ATTR_SEVERITY) is None assert state_w2.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w2.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w2.attributes.get(ATTR_ID) is None assert state_w2.attributes.get(ATTR_SENT) is None assert state_w2.attributes.get(ATTR_START) is None @@ -104,6 +117,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w3.attributes.get(ATTR_SENDER) is None assert state_w3.attributes.get(ATTR_SEVERITY) is None assert state_w3.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w3.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w3.attributes.get(ATTR_ID) is None assert state_w3.attributes.get(ATTR_SENT) is None assert state_w3.attributes.get(ATTR_START) is None @@ -121,6 +135,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w4.attributes.get(ATTR_SENDER) is None assert state_w4.attributes.get(ATTR_SEVERITY) is None assert state_w4.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w4.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w4.attributes.get(ATTR_ID) is None assert state_w4.attributes.get(ATTR_SENT) is None assert state_w4.attributes.get(ATTR_START) is None @@ -138,6 +153,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w5.attributes.get(ATTR_SENDER) is None assert state_w5.attributes.get(ATTR_SEVERITY) is None assert state_w5.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w5.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w5.attributes.get(ATTR_ID) is None assert state_w5.attributes.get(ATTR_SENT) is None assert state_w5.attributes.get(ATTR_START) is None @@ -184,6 +200,10 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: state_w1.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "Waschen sich regelmäßig und gründlich die Hände." ) + assert ( + state_w1.attributes.get(ATTR_AFFECTED_AREAS) + == "Bundesland: Freie Hansestadt Bremen, Land Berlin, Land Hessen, Land Nordrhein-Westfalen, Land Brandenburg, Freistaat Bayern, Land Mecklenburg-Vorpommern, Land Rheinland-Pfalz, Freistaat Sachsen, Land Schleswig-Holstein, Freie und Hansestadt Hamburg, Freistaat Thüringen, Land Niedersachsen, Land Saarland, Land Sachsen-Anhalt, Land Baden-Württemberg" + ) assert state_w1.attributes.get(ATTR_ID) == "mow.DE-BW-S-SE018-20211102-18-001" assert state_w1.attributes.get(ATTR_SENT) == "2021-11-02T20:07:16+01:00" assert state_w1.attributes.get(ATTR_START) == "" @@ -201,6 +221,10 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: state_w2.attributes.get(ATTR_DESCRIPTION) == "Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden." ) + assert ( + state_w2.attributes.get(ATTR_AFFECTED_AREAS) + == "Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere." + ) assert state_w2.attributes.get(ATTR_SENDER) == "Deutscher Wetterdienst" assert state_w2.attributes.get(ATTR_SEVERITY) == "Minor" assert state_w2.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "" @@ -221,6 +245,7 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w3.attributes.get(ATTR_SENDER) is None assert state_w3.attributes.get(ATTR_SEVERITY) is None assert state_w3.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w3.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w3.attributes.get(ATTR_ID) is None assert state_w3.attributes.get(ATTR_SENT) is None assert state_w3.attributes.get(ATTR_START) is None @@ -238,6 +263,7 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w4.attributes.get(ATTR_SENDER) is None assert state_w4.attributes.get(ATTR_SEVERITY) is None assert state_w4.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w4.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w4.attributes.get(ATTR_ID) is None assert state_w4.attributes.get(ATTR_SENT) is None assert state_w4.attributes.get(ATTR_START) is None @@ -255,6 +281,7 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w5.attributes.get(ATTR_SENDER) is None assert state_w5.attributes.get(ATTR_SEVERITY) is None assert state_w5.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w5.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w5.attributes.get(ATTR_ID) is None assert state_w5.attributes.get(ATTR_SENT) is None assert state_w5.attributes.get(ATTR_START) is None @@ -262,3 +289,63 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert entry_w5.unique_id == "083350000000-5" assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + +async def test_sensors_with_area_filter(hass: HomeAssistant) -> None: + """Test the creation and values of the NINA sensors with an area filter.""" + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_AREA + ) + + entity_registry: er = er.async_get(hass) + conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(conf_entry.entry_id) + await hass.async_block_till_done() + + assert conf_entry.state == ConfigEntryState.LOADED + + state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1") + entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1") + + assert state_w1.state == STATE_ON + + assert entry_w1.unique_id == "083350000000-1" + assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2") + entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2") + + assert state_w2.state == STATE_OFF + + assert entry_w2.unique_id == "083350000000-2" + assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3") + entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3") + + assert state_w3.state == STATE_OFF + + assert entry_w3.unique_id == "083350000000-3" + assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4") + entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4") + + assert state_w4.state == STATE_OFF + + assert entry_w4.unique_id == "083350000000-4" + assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5") + entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5") + + assert state_w5.state == STATE_OFF + + assert entry_w5.unique_id == "083350000000-5" + assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 194f0298dd5..aad24691f42 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -10,6 +10,7 @@ from pynina import ApiError from homeassistant import data_entry_flow from homeassistant.components.nina.const import ( + CONF_AREA_FILTER, CONF_HEADLINE_FILTER, CONF_MESSAGE_SLOTS, CONF_REGIONS, @@ -38,6 +39,7 @@ DUMMY_DATA: dict[str, Any] = { CONST_REGION_R_TO_U: ["072320000000_0", "072320000000_1"], CONST_REGION_V_TO_Z: ["081270000000_0", "081270000000_1"], CONF_HEADLINE_FILTER: ".*corona.*", + CONF_AREA_FILTER: ".*", } DUMMY_RESPONSE_REGIONS: dict[str, Any] = json.loads( @@ -146,6 +148,7 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: title="NINA", data={ CONF_HEADLINE_FILTER: deepcopy(DUMMY_DATA[CONF_HEADLINE_FILTER]), + CONF_AREA_FILTER: deepcopy(DUMMY_DATA[CONF_AREA_FILTER]), CONF_MESSAGE_SLOTS: deepcopy(DUMMY_DATA[CONF_MESSAGE_SLOTS]), CONST_REGION_A_TO_D: deepcopy(DUMMY_DATA[CONST_REGION_A_TO_D]), CONF_REGIONS: {"095760000000": "Aach"}, @@ -184,6 +187,7 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: assert dict(config_entry.data) == { CONF_HEADLINE_FILTER: deepcopy(DUMMY_DATA[CONF_HEADLINE_FILTER]), + CONF_AREA_FILTER: deepcopy(DUMMY_DATA[CONF_AREA_FILTER]), CONF_MESSAGE_SLOTS: deepcopy(DUMMY_DATA[CONF_MESSAGE_SLOTS]), CONST_REGION_A_TO_D: ["072350000000_1"], CONST_REGION_E_TO_H: [], diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py index 826b8e422ed..da73c8d8711 100644 --- a/tests/components/nina/test_init.py +++ b/tests/components/nina/test_init.py @@ -16,6 +16,7 @@ from tests.common import MockConfigEntry ENTRY_DATA: dict[str, Any] = { "slots": 5, "headline_filter": ".*corona.*", + "area_filter": ".*", "regions": {"083350000000": "Aach, Stadt"}, } diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 37c0b175faa..23758fe345d 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -506,7 +506,7 @@ async def test_restore_number_save_state( assert state["entity_id"] == entity0.entity_id extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] assert extra_data == RESTORE_DATA - assert type(extra_data["native_value"]) == float + assert isinstance(extra_data["native_value"], float) @pytest.mark.parametrize( @@ -818,22 +818,22 @@ async def test_name(hass: HomeAssistant) -> None: ), ) - # Unnamed sensor without device class -> no name + # Unnamed number without device class -> no name entity1 = NumberEntity() entity1.entity_id = "number.test1" - # Unnamed sensor with device class but has_entity_name False -> no name + # Unnamed number with device class but has_entity_name False -> no name entity2 = NumberEntity() entity2.entity_id = "number.test2" entity2._attr_device_class = NumberDeviceClass.TEMPERATURE - # Unnamed sensor with device class and has_entity_name True -> named + # Unnamed number with device class and has_entity_name True -> named entity3 = NumberEntity() entity3.entity_id = "number.test3" entity3._attr_device_class = NumberDeviceClass.TEMPERATURE entity3._attr_has_entity_name = True - # Unnamed sensor with device class and has_entity_name True -> named + # Unnamed number with device class and has_entity_name True -> named entity4 = NumberEntity() entity4.entity_id = "number.test4" entity4.entity_description = NumberEntityDescription( diff --git a/tests/components/nws/snapshots/test_weather.ambr b/tests/components/nws/snapshots/test_weather.ambr new file mode 100644 index 00000000000..0dddca954be --- /dev/null +++ b/tests/components/nws/snapshots/test_weather.ambr @@ -0,0 +1,229 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.4 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.5 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_subscription[hourly-weather.abc_daynight] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[hourly-weather.abc_daynight].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily-weather.abc_hourly] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily-weather.abc_hourly].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 06d2c2006d8..54069eec02c 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -3,7 +3,9 @@ from datetime import timedelta from unittest.mock import patch import aiohttp +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import nws from homeassistant.components.weather import ( @@ -11,6 +13,7 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -31,6 +34,7 @@ from .const import ( ) from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator @pytest.mark.parametrize( @@ -354,10 +358,10 @@ async def test_error_forecast_hourly( assert state.state == ATTR_CONDITION_SUNNY -async def test_forecast_hourly_disable_enable( - hass: HomeAssistant, mock_simple_nws, no_sensor -) -> None: - """Test error during update forecast hourly.""" +async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + entry = MockConfigEntry( domain=nws.DOMAIN, data=NWS_CONFIG, @@ -367,17 +371,203 @@ async def test_forecast_hourly_disable_enable( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("weather")) == 1 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +async def test_legacy_config_entry(hass: HomeAssistant, no_sensor) -> None: + """Test the expected entities are created.""" registry = er.async_get(hass) - entry = registry.async_get_or_create( + # Pre-create the hourly entity + registry.async_get_or_create( WEATHER_DOMAIN, nws.DOMAIN, "35_-75_hourly", ) - assert entry.disabled is True - # Test enabling entity - updated_entry = registry.async_update_entity( - entry.entity_id, **{"disabled_by": None} + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, ) - assert updated_entry != entry - assert updated_entry.disabled is False + 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_entity_ids("weather")) == 2 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + +async def test_forecast_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_simple_nws, + no_sensor, +) -> None: + """Test multiple forecast.""" + instance = mock_simple_nws.return_value + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + instance.update_observation.assert_called_once() + instance.update_forecast.assert_called_once() + instance.update_forecast_hourly.assert_called_once() + + for forecast_type in ("twice_daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should use cached data + instance.update_observation.assert_called_once() + instance.update_forecast.assert_called_once() + instance.update_forecast_hourly.assert_called_once() + + # Trigger data refetch + freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert instance.update_observation.call_count == 2 + assert instance.update_forecast.call_count == 2 + assert instance.update_forecast_hourly.call_count == 1 + + for forecast_type in ("twice_daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should update the hourly forecast + assert instance.update_observation.call_count == 2 + assert instance.update_forecast.call_count == 2 + assert instance.update_forecast_hourly.call_count == 2 + + # third update fails, but data is cached + instance.update_forecast_hourly.side_effect = aiohttp.ClientError + freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # after additional 35 minutes data caching expires, data is no longer shown + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] == [] + + +@pytest.mark.parametrize( + ("forecast_type", "entity_id"), + [("hourly", "weather.abc_daynight"), ("twice_daily", "weather.abc_hourly")], +) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_simple_nws, + no_sensor, + forecast_type: str, + entity_id: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + registry = er.async_get(hass) + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + nws.DOMAIN, + "35_-75_hourly", + suggested_object_id="abc_hourly", + ) + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py index 980fe14970a..e9365e36b24 100644 --- a/tests/components/nzbget/test_sensor.py +++ b/tests/components/nzbget/test_sensor.py @@ -34,14 +34,14 @@ async def test_sensors(hass: HomeAssistant, nzbget_api) -> None: ), "average_speed": ( "AverageDownloadRate", - "1.19", + "1.250000", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), "download_paused": ("DownloadPaused", "False", None, None), "speed": ( "DownloadRate", - "2.38", + "2.500000", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), @@ -68,7 +68,7 @@ async def test_sensors(hass: HomeAssistant, nzbget_api) -> None: "uptime": ("UpTimeSec", uptime.isoformat(), None, SensorDeviceClass.TIMESTAMP), "speed_limit": ( "DownloadLimit", - "0.95", + "1.000000", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index a388aeae106..2ba657c77d5 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -1,5 +1,5 @@ """The tests for Octoptint binary sensor module.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import patch from homeassistant.core import HomeAssistant @@ -24,7 +24,7 @@ async def test_sensors(hass: HomeAssistant) -> None: } with patch( "homeassistant.util.dt.utcnow", - return_value=datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=timezone.utc), + return_value=datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC), ): await init_integration(hass, "sensor", printer=printer, job=job) diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py index 0f7dc8d242b..bcaa9ad611f 100644 --- a/tests/components/onboarding/test_init.py +++ b/tests/components/onboarding/test_init.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component from . import mock_storage -from tests.common import MockUser, mock_coro +from tests.common import MockUser # Temporarily: if auth not active, always set onboarded=True @@ -31,7 +31,6 @@ async def test_setup_views_if_not_onboarded(hass: HomeAssistant) -> None: """Test if onboarding is not done, we setup views.""" with patch( "homeassistant.components.onboarding.views.async_setup", - return_value=mock_coro(), ) as mock_setup: assert await async_setup_component(hass, "onboarding", {}) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index bbe50f9a810..c888381230c 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -117,6 +117,8 @@ def mock_default_integrations(): "homeassistant.components.met.async_setup_entry", return_value=True ), patch( "homeassistant.components.radio_browser.async_setup_entry", return_value=True + ), patch( + "homeassistant.components.shopping_list.async_setup_entry", return_value=True ): yield @@ -453,6 +455,27 @@ async def test_onboarding_core_sets_up_met( assert len(hass.config_entries.async_entries("met")) == 1 +async def test_onboarding_core_sets_up_shopping_list( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + mock_default_integrations, +) -> None: + """Test finishing the core step set up the shopping list.""" + mock_storage(hass_storage, {"done": [const.STEP_USER]}) + + assert await async_setup_component(hass, "onboarding", {}) + await hass.async_block_till_done() + + client = await hass_client() + resp = await client.post("/api/onboarding/core_config") + + assert resp.status == 200 + + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries("shopping_list")) == 1 + + async def test_onboarding_core_sets_up_google_translate( hass: HomeAssistant, hass_storage: dict[str, Any], diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 6c18c1ec652..0664d7e5402 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -105,7 +105,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/10.111111111111/temperature', 'unit_of_measurement': , }), @@ -187,7 +187,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/temperature', 'unit_of_measurement': , }), @@ -217,7 +217,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/pressure', 'unit_of_measurement': , }), @@ -589,7 +589,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/22.111111111111/temperature', 'unit_of_measurement': , }), @@ -671,7 +671,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/26.111111111111/temperature', 'unit_of_measurement': , }), @@ -701,7 +701,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'humidity', + 'translation_key': None, 'unique_id': '/26.111111111111/humidity', 'unit_of_measurement': '%', }), @@ -851,7 +851,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/26.111111111111/B1-R1-A/pressure', 'unit_of_measurement': , }), @@ -881,7 +881,7 @@ 'original_name': 'Illuminance', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'illuminance', + 'translation_key': None, 'unique_id': '/26.111111111111/S3-R1-A/illuminance', 'unit_of_measurement': 'lx', }), @@ -1203,7 +1203,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/28.111111111111/temperature', 'unit_of_measurement': , }), @@ -1285,7 +1285,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/28.222222222222/temperature', 'unit_of_measurement': , }), @@ -1367,7 +1367,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/28.222222222223/temperature', 'unit_of_measurement': , }), @@ -1486,7 +1486,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/30.111111111111/temperature', 'unit_of_measurement': , }), @@ -1546,7 +1546,7 @@ 'original_name': 'Voltage', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'voltage', + 'translation_key': None, 'unique_id': '/30.111111111111/volt', 'unit_of_measurement': , }), @@ -1740,7 +1740,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/3B.111111111111/temperature', 'unit_of_measurement': , }), @@ -1822,7 +1822,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/42.111111111111/temperature', 'unit_of_measurement': , }), @@ -1904,7 +1904,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/temperature', 'unit_of_measurement': , }), @@ -1934,7 +1934,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/pressure', 'unit_of_measurement': , }), @@ -1964,7 +1964,7 @@ 'original_name': 'Illuminance', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'illuminance', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/light', 'unit_of_measurement': 'lx', }), @@ -1994,7 +1994,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'humidity', + 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/humidity', 'unit_of_measurement': '%', }), @@ -2121,7 +2121,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/temperature', 'unit_of_measurement': , }), @@ -2151,7 +2151,7 @@ 'original_name': 'Pressure', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'pressure', + 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/pressure', 'unit_of_measurement': , }), @@ -2248,7 +2248,7 @@ 'original_name': 'Humidity', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'humidity', + 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/humidity_corrected', 'unit_of_measurement': '%', }), @@ -2308,7 +2308,7 @@ 'original_name': 'Temperature', 'platform': 'onewire', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/temperature', 'unit_of_measurement': , }), diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e10c8791ba9 --- /dev/null +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -0,0 +1,74 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'name': 'TestCamera', + 'password': '**REDACTED**', + 'port': 80, + 'snapshot_auth': 'digest', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'onvif', + 'entry_id': '1', + 'options': dict({ + 'enable_webhooks': True, + 'extra_arguments': '-pred 1', + 'rtsp_transport': 'tcp', + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'aa:bb:cc:dd:ee', + 'version': 1, + }), + 'device': dict({ + 'capabilities': dict({ + 'events': False, + 'imaging': True, + 'ptz': True, + 'snapshot': False, + }), + 'info': dict({ + 'fw_version': 'TestFirmwareVersion', + 'mac': 'aa:bb:cc:dd:ee', + 'manufacturer': 'TestManufacturer', + 'model': 'TestModel', + 'serial_number': 'ABCDEFGHIJK', + }), + 'profiles': list([ + dict({ + 'index': 0, + 'name': 'profile1', + 'ptz': None, + 'token': 'dummy', + 'video': dict({ + 'encoding': 'any', + 'resolution': dict({ + 'height': 480, + 'width': 640, + }), + }), + 'video_source_token': None, + }), + ]), + 'services': dict({ + }), + 'xaddrs': dict({ + }), + }), + 'events': dict({ + 'pullpoint_manager_state': dict({ + '__type': "", + 'repr': '', + }), + 'webhook_manager_state': dict({ + '__type': "", + 'repr': '', + }), + }), + }) +# --- diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index f87a5f0eff6..af7a68a6e0d 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -1,93 +1,21 @@ """Test ONVIF diagnostics.""" -from unittest.mock import ANY +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import ( - FIRMWARE_VERSION, - MAC, - MANUFACTURER, - MODEL, - SERIAL_NUMBER, - setup_onvif_integration, -) +from . import setup_onvif_integration from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" entry, _, _ = await setup_onvif_integration(hass) - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - - assert diag == { - "config": { - "entry_id": "1", - "version": 1, - "domain": "onvif", - "title": "Mock Title", - "data": { - "name": "TestCamera", - "host": "**REDACTED**", - "port": 80, - "username": "**REDACTED**", - "password": "**REDACTED**", - "snapshot_auth": "digest", - }, - "options": { - "extra_arguments": "-pred 1", - "rtsp_transport": "tcp", - "enable_webhooks": True, - }, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": "aa:bb:cc:dd:ee", - "disabled_by": None, - }, - "device": { - "info": { - "manufacturer": MANUFACTURER, - "model": MODEL, - "fw_version": FIRMWARE_VERSION, - "serial_number": SERIAL_NUMBER, - "mac": MAC, - }, - "capabilities": { - "snapshot": False, - "events": False, - "ptz": True, - "imaging": True, - }, - "profiles": [ - { - "index": 0, - "token": "dummy", - "name": "profile1", - "video": { - "encoding": "any", - "resolution": {"width": 640, "height": 480}, - }, - "ptz": None, - "video_source_token": None, - } - ], - "services": ANY, - "xaddrs": ANY, - }, - "events": { - "pullpoint_manager_state": { - "__type": "", - "repr": "", - }, - "webhook_manager_state": { - "__type": "", - "repr": "", - }, - }, - } + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 1b9f81f60c0..1b145d9d545 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -22,11 +22,13 @@ async def test_default_prompt( snapshot: SnapshotAssertion, ) -> None: """Test that the default prompt works.""" + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) for i in range(3): area_registry.async_create(f"{i}Empty Area") device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "1234")}, name="Test Device", manufacturer="Test Manufacturer", @@ -35,7 +37,7 @@ async def test_default_prompt( ) for i in range(3): device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", f"{i}abcd")}, name="Test Service", manufacturer="Test Manufacturer", @@ -44,7 +46,7 @@ async def test_default_prompt( entry_type=dr.DeviceEntryType.SERVICE, ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "5678")}, name="Test Device 2", manufacturer="Test Manufacturer 2", @@ -52,7 +54,7 @@ async def test_default_prompt( suggested_area="Test Area 2", ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876")}, name="Test Device 3", manufacturer="Test Manufacturer 3", @@ -60,13 +62,13 @@ async def test_default_prompt( suggested_area="Test Area 2", ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "qwer")}, name="Test Device 4", suggested_area="Test Area 2", ) device = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876-disabled")}, name="Test Device 3", manufacturer="Test Manufacturer 3", @@ -77,14 +79,14 @@ async def test_default_prompt( device.id, disabled_by=dr.DeviceEntryDisabler.USER ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876-no-name")}, manufacturer="Test Manufacturer NoName", model="Test Model NoName", suggested_area="Test Area 2", ) device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=entry.entry_id, connections={("test", "9876-integer-values")}, name=1, manufacturer=2, diff --git a/tests/components/openalpr_cloud/test_image_processing.py b/tests/components/openalpr_cloud/test_image_processing.py index dfda0b0d282..8dd8326ddd8 100644 --- a/tests/components/openalpr_cloud/test_image_processing.py +++ b/tests/components/openalpr_cloud/test_image_processing.py @@ -157,7 +157,7 @@ async def test_openalpr_process_image( ] assert len(event_data) == 1 assert event_data[0]["plate"] == "H786P0J" - assert event_data[0]["confidence"] == float(90.436699) + assert event_data[0]["confidence"] == 90.436699 assert event_data[0]["entity_id"] == "image_processing.test_local" diff --git a/tests/components/opensky/__init__.py b/tests/components/opensky/__init__.py index f985f068ab1..e746521c72c 100644 --- a/tests/components/opensky/__init__.py +++ b/tests/components/opensky/__init__.py @@ -1,9 +1,20 @@ """Opensky tests.""" +import json from unittest.mock import patch +from python_opensky import StatesResponse + +from tests.common import load_fixture + def patch_setup_entry() -> bool: """Patch interface.""" return patch( "homeassistant.components.opensky.async_setup_entry", return_value=True ) + + +def get_states_response_fixture(fixture: str) -> StatesResponse: + """Return the states response from json.""" + json_fixture = load_fixture(fixture) + return StatesResponse.parse_obj(json.loads(json_fixture)) diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py index 63e514d0d8f..f74c18773f5 100644 --- a/tests/components/opensky/conftest.py +++ b/tests/components/opensky/conftest.py @@ -1,16 +1,27 @@ """Configure tests for the OpenSky integration.""" from collections.abc import Awaitable, Callable +import json from unittest.mock import patch import pytest from python_opensky import StatesResponse -from homeassistant.components.opensky.const import CONF_ALTITUDE, DOMAIN -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.components.opensky.const import ( + CONF_ALTITUDE, + CONF_CONTRIBUTING_USER, + DOMAIN, +) +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_RADIUS, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]] @@ -32,6 +43,43 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_altitude") +def mock_config_entry_altitude() -> MockConfigEntry: + """Create Opensky entry with altitude in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="OpenSky", + data={ + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + options={ + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 12500.0, + }, + ) + + +@pytest.fixture(name="config_entry_authenticated") +def mock_config_entry_authenticated() -> MockConfigEntry: + """Create authenticated Opensky entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="OpenSky", + data={ + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + options={ + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 12500.0, + CONF_USERNAME: "asd", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + ) + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, @@ -40,9 +88,10 @@ async def mock_setup_integration( async def func(mock_config_entry: MockConfigEntry) -> None: mock_config_entry.add_to_hass(hass) + json_fixture = load_fixture("opensky/states.json") with patch( "python_opensky.OpenSky.get_states", - return_value=StatesResponse(states=[], time=0), + return_value=StatesResponse.parse_obj(json.loads(json_fixture)), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/opensky/fixtures/states.json b/tests/components/opensky/fixtures/states.json new file mode 100644 index 00000000000..7fee53157c8 --- /dev/null +++ b/tests/components/opensky/fixtures/states.json @@ -0,0 +1,105 @@ +{ + "time": 1691244533, + "states": [ + { + "icao24": "3c6708", + "callsign": "DLH459 ", + "origin_country": "Germany", + "time_position": 1691244522, + "last_contact": 1691244522, + "longitude": 5.4445, + "latitude": 52.2991, + "baro_altitude": 12496.8, + "on_ground": false, + "velocity": 259.73, + "true_track": 134.84, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12710.16, + "squawk": "1151", + "spi": false, + "position_source": 0, + "category": 6 + }, + { + "icao24": "3c6708", + "callsign": " ", + "origin_country": "Germany", + "time_position": 1691244522, + "last_contact": 1691244522, + "longitude": 5.4445, + "latitude": 52.2991, + "baro_altitude": 12496.8, + "on_ground": false, + "velocity": 259.73, + "true_track": 134.84, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12710.16, + "squawk": "1151", + "spi": false, + "position_source": 0, + "category": 6 + }, + { + "icao24": "4846df", + "callsign": "", + "origin_country": "Kingdom of the Netherlands", + "time_position": 1691244404, + "last_contact": 1691244404, + "longitude": 4.7441, + "latitude": 52.3076, + "baro_altitude": null, + "on_ground": true, + "velocity": 8.75, + "true_track": 272.81, + "vertical_rate": null, + "sensors": null, + "geo_altitude": null, + "squawk": null, + "spi": false, + "position_source": 0, + "category": 17 + }, + { + "icao24": "4846df", + "callsign": "DLH420 ", + "origin_country": "Kingdom of the Netherlands", + "time_position": 1691244404, + "last_contact": 1691244404, + "longitude": 4.7441, + "latitude": 52.3076, + "baro_altitude": null, + "on_ground": true, + "velocity": 8.75, + "true_track": 272.81, + "vertical_rate": null, + "sensors": null, + "geo_altitude": null, + "squawk": null, + "spi": false, + "position_source": 0, + "category": 17 + }, + { + "icao24": "3e3d01", + "callsign": "ECA2HL ", + "origin_country": "Germany", + "time_position": 1691244533, + "last_contact": 1691244533, + "longitude": 5.5217, + "latitude": 52.4561, + "baro_altitude": 12500.8, + "on_ground": false, + "velocity": 201.9, + "true_track": 82.39, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12733.02, + "squawk": "1071", + "spi": false, + "position_source": 0, + "category": 1 + } + ] +} diff --git a/tests/components/opensky/fixtures/states_1.json b/tests/components/opensky/fixtures/states_1.json new file mode 100644 index 00000000000..bd76428627e --- /dev/null +++ b/tests/components/opensky/fixtures/states_1.json @@ -0,0 +1,45 @@ +{ + "time": 1691244533, + "states": [ + { + "icao24": "4846df", + "callsign": "", + "origin_country": "Kingdom of the Netherlands", + "time_position": 1691244404, + "last_contact": 1691244404, + "longitude": 4.7441, + "latitude": 52.3076, + "baro_altitude": null, + "on_ground": true, + "velocity": 8.75, + "true_track": 272.81, + "vertical_rate": null, + "sensors": null, + "geo_altitude": null, + "squawk": null, + "spi": false, + "position_source": 0, + "category": 17 + }, + { + "icao24": "3e3d01", + "callsign": "ECA2HL ", + "origin_country": "Germany", + "time_position": 1691244533, + "last_contact": 1691244533, + "longitude": 5.5217, + "latitude": 52.4561, + "baro_altitude": 12500.8, + "on_ground": false, + "velocity": 201.9, + "true_track": 82.39, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12733.02, + "squawk": "1071", + "spi": false, + "position_source": 0, + "category": 1 + } + ] +} diff --git a/tests/components/opensky/snapshots/test_sensor.ambr b/tests/components/opensky/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a57b438df67 --- /dev/null +++ b/tests/components/opensky/snapshots/test_sensor.ambr @@ -0,0 +1,44 @@ +# serializer version: 1 +# name: test_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Information provided by the OpenSky Network (https://opensky-network.org)', + 'friendly_name': 'OpenSky', + 'icon': 'mdi:airplane', + 'state_class': , + 'unit_of_measurement': 'flights', + }), + 'context': , + 'entity_id': 'sensor.opensky', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_altitude + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Information provided by the OpenSky Network (https://opensky-network.org)', + 'friendly_name': 'OpenSky', + 'icon': 'mdi:airplane', + 'state_class': , + 'unit_of_measurement': 'flights', + }), + 'context': , + 'entity_id': 'sensor.opensky', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor_updating + list([ + , + ]) +# --- +# name: test_sensor_updating.1 + list([ + , + , + ]) +# --- diff --git a/tests/components/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py index e785a5f3a8f..7fa19762ddf 100644 --- a/tests/components/opensky/test_config_flow.py +++ b/tests/components/opensky/test_config_flow.py @@ -1,19 +1,31 @@ """Test OpenSky config flow.""" from typing import Any +from unittest.mock import patch import pytest +from python_opensky.exceptions import OpenSkyUnauthenticatedError +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.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_PASSWORD, + CONF_RADIUS, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import patch_setup_entry +from . import get_states_response_fixture, patch_setup_entry +from .conftest import ComponentSetup from tests.common import MockConfigEntry @@ -153,3 +165,109 @@ async def test_importing_already_exists_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("user_input", "error"), + [ + ( + {CONF_USERNAME: "homeassistant", CONF_CONTRIBUTING_USER: False}, + "password_missing", + ), + ({CONF_PASSWORD: "secret", CONF_CONTRIBUTING_USER: False}, "username_missing"), + ({CONF_CONTRIBUTING_USER: True}, "no_authentication"), + ( + { + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + "invalid_auth", + ), + ], +) +async def test_options_flow_failures( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + user_input: dict[str, Any], + error: str, +) -> None: + """Test load and unload entry.""" + await setup_integration(config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + with patch( + "python_opensky.OpenSky.authenticate", + side_effect=OpenSkyUnauthenticatedError(), + ): + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_RADIUS: 10000, **user_input}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"]["base"] == error + with patch("python_opensky.OpenSky.authenticate"), patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states_1.json"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + } + + +async def test_options_flow( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + await setup_integration(config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + with patch("python_opensky.OpenSky.authenticate"), patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states_1.json"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + } diff --git a/tests/components/opensky/test_init.py b/tests/components/opensky/test_init.py index be1c21627f0..4c6cb9c3a33 100644 --- a/tests/components/opensky/test_init.py +++ b/tests/components/opensky/test_init.py @@ -1,8 +1,15 @@ """Test OpenSky component setup process.""" from __future__ import annotations +from unittest.mock import patch + +from python_opensky import OpenSkyError +from python_opensky.exceptions import OpenSkyUnauthenticatedError + from homeassistant.components.opensky.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from .conftest import ComponentSetup @@ -26,3 +33,35 @@ async def test_load_unload_entry( state = hass.states.get("sensor.opensky") assert not state + + +async def test_load_entry_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test failure while loading.""" + config_entry.add_to_hass(hass) + with patch( + "python_opensky.OpenSky.get_states", + side_effect=OpenSkyError(), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_load_entry_authentication_failure( + hass: HomeAssistant, + config_entry_authenticated: MockConfigEntry, +) -> None: + """Test auth failure while loading.""" + config_entry_authenticated.add_to_hass(hass) + with patch( + "python_opensky.OpenSky.authenticate", + side_effect=OpenSkyUnauthenticatedError(), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index 1768efebc78..b637a0d0356 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -1,20 +1,116 @@ """OpenSky sensor tests.""" -from homeassistant.components.opensky.const import DOMAIN +from datetime import timedelta +import json +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from python_opensky import StatesResponse +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 HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture + 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.""" - 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 + json_fixture = load_fixture("opensky/states.json") + with patch( + "python_opensky.OpenSky.get_states", + return_value=StatesResponse.parse_obj(json.loads(json_fixture)), + ): + 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, + config_entry: MockConfigEntry, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +): + """Test setup sensor.""" + await setup_integration(config_entry) + + state = hass.states.get("sensor.opensky") + assert state == snapshot + events = [] + + async def event_listener(event: Event) -> None: + events.append(event) + + hass.bus.async_listen(EVENT_OPENSKY_ENTRY, event_listener) + hass.bus.async_listen(EVENT_OPENSKY_EXIT, event_listener) + assert events == [] + + +async def test_sensor_altitude( + hass: HomeAssistant, + config_entry_altitude: MockConfigEntry, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +): + """Test setup sensor with a set altitude.""" + await setup_integration(config_entry_altitude) + + state = hass.states.get("sensor.opensky") + assert state == snapshot + + +async def test_sensor_updating( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +): + """Test updating sensor.""" + await setup_integration(config_entry) + + def get_states_response_fixture(fixture: str) -> StatesResponse: + json_fixture = load_fixture(fixture) + return StatesResponse.parse_obj(json.loads(json_fixture)) + + events = [] + + async def event_listener(event: Event) -> None: + events.append(event) + + hass.bus.async_listen(EVENT_OPENSKY_ENTRY, event_listener) + hass.bus.async_listen(EVENT_OPENSKY_EXIT, event_listener) + + async def skip_time_and_check_events() -> None: + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert events == snapshot + + with patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states_1.json"), + ): + await skip_time_and_check_events() + with patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states.json"), + ): + await skip_time_and_check_events() diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 6a45a0dcc56..f9ae457a80e 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -68,6 +68,110 @@ async def test_form( assert mock_login.call_count == 1 +async def test_form_with_mfa( + recorder_mock: Recorder, 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 not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == FlowResultType.FORM + assert not result2["errors"] + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + ) as mock_login: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "totp_secret": "test-totp", + }, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Consolidated Edison (ConEd) (test-username)" + assert result3["data"] == { + "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password", + "totp_secret": "test-totp", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_login.call_count == 1 + + +async def test_form_with_mfa_bad_secret( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test MFA asks for password again when validation fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == FlowResultType.FORM + assert not result2["errors"] + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ) as mock_login: + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "totp_secret": "test-totp", + }, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "base": "invalid_auth", + } + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + ) as mock_login: + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "updated-password", + "totp_secret": "updated-totp", + }, + ) + + assert result4["type"] == FlowResultType.CREATE_ENTRY + assert result4["title"] == "Consolidated Edison (ConEd) (test-username)" + assert result4["data"] == { + "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "updated-password", + "totp_secret": "updated-totp", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_login.call_count == 1 + + @pytest.mark.parametrize( ("api_exception", "expected_error"), [ @@ -196,7 +300,7 @@ async def test_form_valid_reauth( assert result["reason"] == "reauth_successful" await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN)[0].data == { + assert mock_config_entry.data == { "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password2", @@ -204,3 +308,54 @@ async def test_form_valid_reauth( assert len(mock_unload_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 + + +async def test_form_valid_reauth_with_mfa( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that we can handle a valid reauth.""" + hass.config_entries.async_update_entry( + mock_config_entry, + data={ + **mock_config_entry.data, + # Requires MFA + "utility": "Consolidated Edison (ConEd)", + }, + ) + mock_config_entry.state = ConfigEntryState.LOADED + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + ) as mock_login: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password2", + "totp_secret": "test-totp", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + await hass.async_block_till_done() + assert mock_config_entry.data == { + "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password2", + "totp_secret": "test-totp", + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_login.call_count == 1 diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index d43997fe7ed..49de6db6e13 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -31,6 +31,7 @@ async def test_sensors( hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test setting up creates the sensors.""" + start_monotonic = time.monotonic() entry = MockConfigEntry( domain=DOMAIN, unique_id=ORALB_SERVICE_INFO.address, @@ -59,6 +60,30 @@ async def test_sensors( assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # All of these devices are sleepy so we should still be available + toothbrush_sensor = hass.states.get( + "sensor.smart_series_7000_48be_toothbrush_state" + ) + toothbrush_sensor_attrs = toothbrush_sensor.attributes + assert toothbrush_sensor.state == "running" + async def test_sensors_io_series_4( hass: HomeAssistant, entity_registry_enabled_by_default: None @@ -103,6 +128,11 @@ async def test_sensors_io_series_4( async_address_present(hass, ORALB_IO_SERIES_4_SERVICE_INFO.address) is False ) + 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() + toothbrush_sensor = hass.states.get("sensor.io_series_4_48be_mode") # Sleepy devices should keep their state over time assert toothbrush_sensor.state == "gum care" diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 9f2fd4a4355..a30275d3569 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -26,3 +26,5 @@ DATASET_INSECURE_PASSPHRASE = bytes.fromhex( "0A336069051000112233445566778899AABBCCDDEEFA030E4F70656E54687265616444656D6F01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) + +TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index e7d5ac8980e..75922e99aa0 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -6,7 +6,12 @@ import pytest from homeassistant.components import otbr from homeassistant.core import HomeAssistant -from . import CONFIG_ENTRY_DATA_MULTIPAN, CONFIG_ENTRY_DATA_THREAD, DATASET_CH16 +from . import ( + CONFIG_ENTRY_DATA_MULTIPAN, + CONFIG_ENTRY_DATA_THREAD, + DATASET_CH16, + TEST_BORDER_AGENT_ID, +) from tests.common import MockConfigEntry @@ -23,6 +28,8 @@ async def otbr_config_entry_multipan_fixture(hass): 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( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests @@ -41,6 +48,8 @@ async def otbr_config_entry_thread_fixture(hass): 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( "homeassistant.components.otbr.util.compute_pskc" ): # Patch to speed up tests diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 49694cf5585..1b5c1e8b60a 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -7,7 +7,7 @@ import aiohttp import pytest import python_otbr_api -from homeassistant.components import otbr +from homeassistant.components import otbr, thread from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -21,6 +21,7 @@ from . import ( DATASET_CH16, DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE, + TEST_BORDER_AGENT_ID, ) from tests.common import MockConfigEntry @@ -36,6 +37,7 @@ DATASET_NO_CHANNEL = bytes.fromhex( async def test_import_dataset(hass: HomeAssistant) -> None: """Test the active dataset is imported at setup.""" issue_registry = ir.async_get(hass) + assert await thread.async_get_preferred_dataset(hass) is None config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA_MULTIPAN, @@ -47,11 +49,16 @@ async def test_import_dataset(hass: HomeAssistant) -> None: with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( - "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add: + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID + ): assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) + 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 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}" ) @@ -82,12 +89,16 @@ async def test_import_share_radio_channel_collision( 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( "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()) + mock_add.assert_called_once_with( + otbr.DOMAIN, DATASET_CH16.hex(), TEST_BORDER_AGENT_ID.hex() + ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"otbr_zha_channel_collision_{config_entry.entry_id}", @@ -115,12 +126,16 @@ async def test_import_share_radio_no_channel_collision( config_entry.add_to_hass(hass) with patch( "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( "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()) + mock_add.assert_called_once_with( + otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + ) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"otbr_zha_channel_collision_{config_entry.entry_id}", @@ -146,12 +161,16 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N config_entry.add_to_hass(hass) with patch( "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( "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()) + mock_add.assert_called_once_with( + otbr.DOMAIN, dataset.hex(), TEST_BORDER_AGENT_ID.hex() + ) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) @@ -179,6 +198,25 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: assert not await hass.config_entries.async_setup(config_entry.entry_id) +async def test_border_agent_id_not_supported(hass: HomeAssistant) -> None: + """Test border router does not support border agent ID.""" + + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA_MULTIPAN, + domain=otbr.DOMAIN, + options={}, + 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", + side_effect=python_otbr_api.GetBorderAgentIdNotSupportedError, + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + + async def test_config_entry_update(hass: HomeAssistant) -> None: """Test update config entry settings.""" config_entry = MockConfigEntry( @@ -190,6 +228,7 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) 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) 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_util.py b/tests/components/otbr/test_util.py index f8ed79b91ee..171a607d200 100644 --- a/tests/components/otbr/test_util.py +++ b/tests/components/otbr/test_util.py @@ -1,7 +1,12 @@ """Test OTBR Utility functions.""" +from unittest.mock import patch + +import pytest +import python_otbr_api from homeassistant.components import otbr from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" @@ -23,3 +28,75 @@ async def test_get_allowed_channel( # OTBR no multipan + multipan using channel 15 -> no restriction multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None + + +async def test_factory_reset(hass: HomeAssistant, otbr_config_entry_multipan) -> None: + """Test factory_reset.""" + data: otbr.OTBRData = hass.data[otbr.DOMAIN] + + with patch("python_otbr_api.OTBR.factory_reset") as factory_reset_mock, patch( + "python_otbr_api.OTBR.delete_active_dataset" + ) as delete_active_dataset_mock: + await data.factory_reset() + + delete_active_dataset_mock.assert_not_called() + factory_reset_mock.assert_called_once_with() + + +async def test_factory_reset_not_supported( + hass: HomeAssistant, otbr_config_entry_multipan +) -> None: + """Test factory_reset.""" + data: otbr.OTBRData = hass.data[otbr.DOMAIN] + + with patch( + "python_otbr_api.OTBR.factory_reset", + side_effect=python_otbr_api.FactoryResetNotSupportedError, + ) as factory_reset_mock, patch( + "python_otbr_api.OTBR.delete_active_dataset" + ) as delete_active_dataset_mock: + await data.factory_reset() + + delete_active_dataset_mock.assert_called_once_with() + factory_reset_mock.assert_called_once_with() + + +async def test_factory_reset_error_1( + hass: HomeAssistant, otbr_config_entry_multipan +) -> None: + """Test factory_reset.""" + data: otbr.OTBRData = hass.data[otbr.DOMAIN] + + with patch( + "python_otbr_api.OTBR.factory_reset", + side_effect=python_otbr_api.OTBRError, + ) as factory_reset_mock, patch( + "python_otbr_api.OTBR.delete_active_dataset" + ) as delete_active_dataset_mock, pytest.raises( + HomeAssistantError + ): + await data.factory_reset() + + delete_active_dataset_mock.assert_not_called() + factory_reset_mock.assert_called_once_with() + + +async def test_factory_reset_error_2( + hass: HomeAssistant, otbr_config_entry_multipan +) -> None: + """Test factory_reset.""" + data: otbr.OTBRData = hass.data[otbr.DOMAIN] + + with patch( + "python_otbr_api.OTBR.factory_reset", + side_effect=python_otbr_api.FactoryResetNotSupportedError, + ) as factory_reset_mock, patch( + "python_otbr_api.OTBR.delete_active_dataset", + side_effect=python_otbr_api.OTBRError, + ) as delete_active_dataset_mock, pytest.raises( + HomeAssistantError + ): + await data.factory_reset() + + delete_active_dataset_mock.assert_called_once_with() + factory_reset_mock.assert_called_once_with() diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index b5dd7aa62c4..cba046a2a9d 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -8,7 +8,7 @@ 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 +from . import BASE_URL, DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -31,7 +31,14 @@ async def test_get_info( with patch( "python_otbr_api.OTBR.get_active_dataset", return_value=python_otbr_api.ActiveDataSet(channel=16), - ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16): + ), 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=bytes.fromhex("4EF6C4F3FF750626"), + ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() @@ -40,6 +47,8 @@ async def test_get_info( "url": BASE_URL, "active_dataset_tlvs": DATASET_CH16.hex().lower(), "channel": 16, + "border_agent_id": TEST_BORDER_AGENT_ID.hex(), + "extended_address": "4EF6C4F3FF750626".lower(), } @@ -68,12 +77,14 @@ async def test_get_info_fetch_fails( with patch( "python_otbr_api.OTBR.get_active_dataset", side_effect=python_otbr_api.OTBRError, + ), patch( + "python_otbr_api.OTBR.get_border_agent_id", return_value=TEST_BORDER_AGENT_ID ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() assert not msg["success"] - assert msg["error"]["code"] == "get_dataset_failed" + assert msg["error"]["code"] == "otbr_info_failed" async def test_create_network( @@ -87,8 +98,8 @@ async def test_create_network( with patch( "python_otbr_api.OTBR.create_active_dataset" ) as create_dataset_mock, patch( - "python_otbr_api.OTBR.delete_active_dataset" - ) as delete_dataset_mock, patch( + "python_otbr_api.OTBR.factory_reset" + ) as factory_reset_mock, patch( "python_otbr_api.OTBR.set_enabled" ) as set_enabled_mock, patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 @@ -104,12 +115,12 @@ async def test_create_network( create_dataset_mock.assert_called_once_with( python_otbr_api.models.ActiveDataSet(channel=15, network_name="home-assistant") ) - delete_dataset_mock.assert_called_once_with() + 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()) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex(), None) async def test_create_network_no_entry( @@ -157,7 +168,7 @@ async def test_create_network_fails_2( ), patch( "python_otbr_api.OTBR.create_active_dataset", side_effect=python_otbr_api.OTBRError, - ), patch("python_otbr_api.OTBR.delete_active_dataset"): + ), patch("python_otbr_api.OTBR.factory_reset"): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -178,7 +189,7 @@ async def test_create_network_fails_3( ), patch( "python_otbr_api.OTBR.create_active_dataset", ), patch( - "python_otbr_api.OTBR.delete_active_dataset" + "python_otbr_api.OTBR.factory_reset" ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -200,7 +211,7 @@ async def test_create_network_fails_4( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, ), patch( - "python_otbr_api.OTBR.delete_active_dataset" + "python_otbr_api.OTBR.factory_reset" ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -219,7 +230,7 @@ async def test_create_network_fails_5( with patch("python_otbr_api.OTBR.set_enabled"), patch( "python_otbr_api.OTBR.create_active_dataset" ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None), patch( - "python_otbr_api.OTBR.delete_active_dataset" + "python_otbr_api.OTBR.factory_reset" ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() @@ -238,14 +249,14 @@ async def test_create_network_fails_6( with patch("python_otbr_api.OTBR.set_enabled"), patch( "python_otbr_api.OTBR.create_active_dataset" ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None), patch( - "python_otbr_api.OTBR.delete_active_dataset", + "python_otbr_api.OTBR.factory_reset", side_effect=python_otbr_api.OTBRError, ): await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() assert not msg["success"] - assert msg["error"]["code"] == "delete_active_dataset_failed" + assert msg["error"]["code"] == "factory_reset_failed" async def test_set_network( @@ -435,58 +446,6 @@ async def test_set_network_fails_3( assert msg["error"]["code"] == "set_enabled_failed" -async def test_get_extended_address( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan, - websocket_client, -) -> None: - """Test get extended address.""" - - with patch( - "python_otbr_api.OTBR.get_extended_address", - return_value=bytes.fromhex("4EF6C4F3FF750626"), - ): - await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) - msg = await websocket_client.receive_json() - - assert msg["success"] - assert msg["result"] == {"extended_address": "4EF6C4F3FF750626".lower()} - - -async def test_get_extended_address_no_entry( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test get extended address.""" - await async_setup_component(hass, "otbr", {}) - websocket_client = await hass_ws_client(hass) - await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) - - msg = await websocket_client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "not_loaded" - - -async def test_get_extended_address_fetch_fails( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - otbr_config_entry_multipan, - websocket_client, -) -> None: - """Test get extended address.""" - with patch( - "python_otbr_api.OTBR.get_extended_address", - side_effect=python_otbr_api.OTBRError, - ): - await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) - msg = await websocket_client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == "get_extended_address_failed" - - async def test_set_channel( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 865c5e52770..1be21e8b1b2 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_NOT_HOME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro +from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import ClientSessionGenerator USER = "greg" @@ -279,7 +279,7 @@ BAD_MESSAGE = {"_type": "unsupported", "tst": 1} BAD_JSON_PREFIX = "--$this is bad json#--" BAD_JSON_SUFFIX = "** and it ends here ^^" -# pylint: disable=invalid-name, len-as-condition +# pylint: disable=len-as-condition @pytest.fixture @@ -311,8 +311,6 @@ def context(hass, setup_comp): orig_context = owntracks.OwnTracksContext context = None - # pylint: disable=no-value-for-parameter - def store_context(*args): """Store the context.""" nonlocal context @@ -1305,7 +1303,7 @@ async def test_not_implemented_message(hass: HomeAssistant, context) -> None: """Handle not implemented message type.""" patch_handler = patch( "homeassistant.components.owntracks.messages.async_handle_not_impl_msg", - return_value=mock_coro(False), + return_value=False, ) patch_handler.start() assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) @@ -1316,7 +1314,7 @@ async def test_unsupported_message(hass: HomeAssistant, context) -> None: """Handle not implemented message type.""" patch_handler = patch( "homeassistant.components.owntracks.messages.async_handle_unsupported_msg", - return_value=mock_coro(False), + return_value=False, ) patch_handler.start() assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) @@ -1395,7 +1393,7 @@ def config_context(hass, setup_comp): """Set up the mocked context.""" patch_load = patch( "homeassistant.components.device_tracker.async_load_config", - return_value=mock_coro([]), + return_value=[], ) patch_load.start() @@ -1503,7 +1501,7 @@ async def test_encrypted_payload_no_topic_key(hass: HomeAssistant, setup_comp) - async def test_encrypted_payload_libsodium(hass: HomeAssistant, setup_comp) -> None: """Test sending encrypted message payload.""" try: - import nacl # noqa: F401 pylint: disable=unused-import + import nacl # noqa: F401 except (ImportError, OSError): pytest.skip("PyNaCl/libsodium is not installed") return diff --git a/tests/components/pegel_online/__init__.py b/tests/components/pegel_online/__init__.py index ac3f9bda7dd..aed9949c927 100644 --- a/tests/components/pegel_online/__init__.py +++ b/tests/components/pegel_online/__init__.py @@ -8,13 +8,13 @@ class PegelOnlineMock: self, nearby_stations=None, station_details=None, - station_measurement=None, + station_measurements=None, side_effect=None, ) -> None: """Init the mock.""" self.nearby_stations = nearby_stations self.station_details = station_details - self.station_measurement = station_measurement + self.station_measurements = station_measurements self.side_effect = side_effect async def async_get_nearby_stations(self, *args): @@ -29,11 +29,11 @@ class PegelOnlineMock: raise self.side_effect return self.station_details - async def async_get_station_measurement(self, *args): - """Mock async_get_station_measurement.""" + async def async_get_station_measurements(self, *args): + """Mock async_get_station_measurements.""" if self.side_effect: raise self.side_effect - return self.station_measurement + return self.station_measurements def override_side_effect(self, side_effect): """Override the side_effect.""" diff --git a/tests/components/pegel_online/const.py b/tests/components/pegel_online/const.py new file mode 100644 index 00000000000..4ab28301f90 --- /dev/null +++ b/tests/components/pegel_online/const.py @@ -0,0 +1,203 @@ +"""Constants for pegel_online tests.""" + +from aiopegelonline.models import Station, StationMeasurements + +from homeassistant.components.pegel_online.const import CONF_STATION + +MOCK_STATION_DETAILS_MEISSEN = Station( + { + "uuid": "85d686f1-xxxx-xxxx-xxxx-3207b50901a7", + "number": "501060", + "shortname": "MEISSEN", + "longname": "MEISSEN", + "km": 82.2, + "agency": "STANDORT DRESDEN", + "longitude": 13.475467710324812, + "latitude": 51.16440557554545, + "water": {"shortname": "ELBE", "longname": "ELBE"}, + } +) + +MOCK_STATION_DETAILS_DRESDEN = Station( + { + "uuid": "70272185-xxxx-xxxx-xxxx-43bea330dcae", + "number": "501060", + "shortname": "DRESDEN", + "longname": "DRESDEN", + "km": 55.63, + "agency": "STANDORT DRESDEN", + "longitude": 13.738831783620384, + "latitude": 51.054459765598125, + "water": {"shortname": "ELBE", "longname": "ELBE"}, + } +) +MOCK_CONFIG_ENTRY_DATA_DRESDEN = {CONF_STATION: "70272185-xxxx-xxxx-xxxx-43bea330dcae"} +MOCK_STATION_MEASUREMENT_DRESDEN = StationMeasurements( + [ + { + "shortname": "W", + "longname": "WASSERSTAND ROHDATEN", + "unit": "cm", + "equidistance": 15, + "currentMeasurement": { + "timestamp": "2023-07-26T21:15:00+02:00", + "value": 62, + "stateMnwMhw": "low", + "stateNswHsw": "normal", + }, + "gaugeZero": { + "unit": "m. ü. NHN", + "value": 102.7, + "validFrom": "2019-11-01", + }, + }, + { + "shortname": "Q", + "longname": "ABFLUSS_ROHDATEN", + "unit": "m³/s", + "equidistance": 15, + "currentMeasurement": { + "timestamp": "2023-07-26T06:00:00+02:00", + "value": 88.4, + }, + }, + ] +) + +MOCK_STATION_DETAILS_HANAU_BRIDGE = Station( + { + "uuid": "07374faf-xxxx-xxxx-xxxx-adc0e0784c4b", + "number": "24700347", + "shortname": "HANAU BRÜCKE DFH", + "longname": "HANAU BRÜCKE DFH", + "km": 56.398, + "agency": "ASCHAFFENBURG", + "water": {"shortname": "MAIN", "longname": "MAIN"}, + } +) +MOCK_CONFIG_ENTRY_DATA_HANAU_BRIDGE = { + CONF_STATION: "07374faf-xxxx-xxxx-xxxx-adc0e0784c4b" +} +MOCK_STATION_MEASUREMENT_HANAU_BRIDGE = StationMeasurements( + [ + { + "shortname": "DFH", + "longname": "DURCHFAHRTSHÖHE", + "unit": "cm", + "equidistance": 15, + "currentMeasurement": { + "timestamp": "2023-07-26T19:45:00+02:00", + "value": 715, + }, + "gaugeZero": { + "unit": "m. ü. NHN", + "value": 106.501, + "validFrom": "2019-11-01", + }, + } + ] +) + + +MOCK_STATION_DETAILS_WUERZBURG = Station( + { + "uuid": "915d76e1-xxxx-xxxx-xxxx-4d144cd771cc", + "number": "24300600", + "shortname": "WÜRZBURG", + "longname": "WÜRZBURG", + "km": 251.97, + "agency": "SCHWEINFURT", + "longitude": 9.925968763247354, + "latitude": 49.79620901036012, + "water": {"shortname": "MAIN", "longname": "MAIN"}, + } +) +MOCK_CONFIG_ENTRY_DATA_WUERZBURG = { + CONF_STATION: "915d76e1-xxxx-xxxx-xxxx-4d144cd771cc" +} +MOCK_STATION_MEASUREMENT_WUERZBURG = StationMeasurements( + [ + { + "shortname": "W", + "longname": "WASSERSTAND ROHDATEN", + "unit": "cm", + "equidistance": 15, + "currentMeasurement": { + "timestamp": "2023-07-26T19:15:00+02:00", + "value": 159, + "stateMnwMhw": "normal", + "stateNswHsw": "normal", + }, + "gaugeZero": { + "unit": "m. ü. NHN", + "value": 164.511, + "validFrom": "2019-11-01", + }, + }, + { + "shortname": "LT", + "longname": "LUFTTEMPERATUR", + "unit": "°C", + "equidistance": 60, + "currentMeasurement": { + "timestamp": "2023-07-26T19:00:00+02:00", + "value": 21.2, + }, + }, + { + "shortname": "WT", + "longname": "WASSERTEMPERATUR", + "unit": "°C", + "equidistance": 60, + "currentMeasurement": { + "timestamp": "2023-07-26T19:00:00+02:00", + "value": 22.1, + }, + }, + { + "shortname": "VA", + "longname": "FLIESSGESCHWINDIGKEIT", + "unit": "m/s", + "equidistance": 15, + "currentMeasurement": { + "timestamp": "2023-07-26T19:15:00+02:00", + "value": 0.58, + }, + }, + { + "shortname": "O2", + "longname": "SAUERSTOFFGEHALT", + "unit": "mg/l", + "equidistance": 60, + "currentMeasurement": { + "timestamp": "2023-07-26T19:00:00+02:00", + "value": 8.4, + }, + }, + { + "shortname": "PH", + "longname": "PH-WERT", + "unit": "--", + "equidistance": 60, + "currentMeasurement": { + "timestamp": "2023-07-26T19:00:00+02:00", + "value": 8.1, + }, + }, + { + "shortname": "Q", + "longname": "ABFLUSS", + "unit": "m³/s", + "equidistance": 15, + "currentMeasurement": { + "timestamp": "2023-07-26T19:00:00+02:00", + "value": 102, + }, + }, + ] +) + +MOCK_NEARBY_STATIONS = { + "70272185-xxxx-xxxx-xxxx-43bea330dcae": MOCK_STATION_DETAILS_DRESDEN, + "85d686f1-xxxx-xxxx-xxxx-3207b50901a7": MOCK_STATION_DETAILS_MEISSEN, +} diff --git a/tests/components/pegel_online/test_config_flow.py b/tests/components/pegel_online/test_config_flow.py index ffc2f88d5a8..61f7dc75255 100644 --- a/tests/components/pegel_online/test_config_flow.py +++ b/tests/components/pegel_online/test_config_flow.py @@ -2,12 +2,8 @@ from unittest.mock import patch from aiohttp.client_exceptions import ClientError -from aiopegelonline import Station -from homeassistant.components.pegel_online.const import ( - CONF_STATION, - DOMAIN, -) +from homeassistant.components.pegel_online.const import CONF_STATION, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_LATITUDE, @@ -19,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import PegelOnlineMock +from .const import MOCK_CONFIG_ENTRY_DATA_DRESDEN, MOCK_NEARBY_STATIONS from tests.common import MockConfigEntry @@ -27,38 +24,7 @@ MOCK_USER_DATA_STEP1 = { CONF_RADIUS: 25, } -MOCK_USER_DATA_STEP2 = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} - -MOCK_CONFIG_ENTRY_DATA = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} - -MOCK_NEARBY_STATIONS = { - "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": Station( - { - "uuid": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", - "number": "501060", - "shortname": "DRESDEN", - "longname": "DRESDEN", - "km": 55.63, - "agency": "STANDORT DRESDEN", - "longitude": 13.738831783620384, - "latitude": 51.054459765598125, - "water": {"shortname": "ELBE", "longname": "ELBE"}, - } - ), - "85d686f1-xxxx-xxxx-xxxx-3207b50901a7": Station( - { - "uuid": "85d686f1-xxxx-xxxx-xxxx-3207b50901a7", - "number": "501060", - "shortname": "MEISSEN", - "longname": "MEISSEN", - "km": 82.2, - "agency": "STANDORT DRESDEN", - "longitude": 13.475467710324812, - "latitude": 51.16440557554545, - "water": {"shortname": "ELBE", "longname": "ELBE"}, - } - ), -} +MOCK_USER_DATA_STEP2 = {CONF_STATION: "70272185-xxxx-xxxx-xxxx-43bea330dcae"} async def test_user(hass: HomeAssistant) -> None: @@ -85,7 +51,7 @@ async def test_user(hass: HomeAssistant) -> None: result["flow_id"], user_input=MOCK_USER_DATA_STEP2 ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_STATION] == "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8" + assert result["data"][CONF_STATION] == "70272185-xxxx-xxxx-xxxx-43bea330dcae" assert result["title"] == "DRESDEN ELBE" await hass.async_block_till_done() @@ -97,8 +63,8 @@ async def test_user_already_configured(hass: HomeAssistant) -> None: """Test starting a flow by user with an already configured statioon.""" mock_config = MockConfigEntry( domain=DOMAIN, - data=MOCK_CONFIG_ENTRY_DATA, - unique_id=MOCK_CONFIG_ENTRY_DATA[CONF_STATION], + data=MOCK_CONFIG_ENTRY_DATA_DRESDEN, + unique_id=MOCK_CONFIG_ENTRY_DATA_DRESDEN[CONF_STATION], ) mock_config.add_to_hass(hass) @@ -159,7 +125,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: result["flow_id"], user_input=MOCK_USER_DATA_STEP2 ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_STATION] == "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8" + assert result["data"][CONF_STATION] == "70272185-xxxx-xxxx-xxxx-43bea330dcae" assert result["title"] == "DRESDEN ELBE" await hass.async_block_till_done() @@ -201,7 +167,7 @@ async def test_user_no_stations(hass: HomeAssistant) -> None: result["flow_id"], user_input=MOCK_USER_DATA_STEP2 ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_STATION] == "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8" + assert result["data"][CONF_STATION] == "70272185-xxxx-xxxx-xxxx-43bea330dcae" assert result["title"] == "DRESDEN ELBE" await hass.async_block_till_done() diff --git a/tests/components/pegel_online/test_init.py b/tests/components/pegel_online/test_init.py index 93ade373315..2b5ba3642ec 100644 --- a/tests/components/pegel_online/test_init.py +++ b/tests/components/pegel_online/test_init.py @@ -2,7 +2,6 @@ from unittest.mock import patch from aiohttp.client_exceptions import ClientError -from aiopegelonline import CurrentMeasurement, Station from homeassistant.components.pegel_online.const import ( CONF_STATION, @@ -14,39 +13,27 @@ from homeassistant.core import HomeAssistant from homeassistant.util import utcnow from . import PegelOnlineMock +from .const import ( + MOCK_CONFIG_ENTRY_DATA_DRESDEN, + MOCK_STATION_DETAILS_DRESDEN, + MOCK_STATION_MEASUREMENT_DRESDEN, +) from tests.common import MockConfigEntry, async_fire_time_changed -MOCK_CONFIG_ENTRY_DATA = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} - -MOCK_STATION_DETAILS = Station( - { - "uuid": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", - "number": "501060", - "shortname": "DRESDEN", - "longname": "DRESDEN", - "km": 55.63, - "agency": "STANDORT DRESDEN", - "longitude": 13.738831783620384, - "latitude": 51.054459765598125, - "water": {"shortname": "ELBE", "longname": "ELBE"}, - } -) -MOCK_STATION_MEASUREMENT = CurrentMeasurement("cm", 56) - async def test_update_error(hass: HomeAssistant) -> None: """Tests error during update entity.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_CONFIG_ENTRY_DATA, - unique_id=MOCK_CONFIG_ENTRY_DATA[CONF_STATION], + data=MOCK_CONFIG_ENTRY_DATA_DRESDEN, + unique_id=MOCK_CONFIG_ENTRY_DATA_DRESDEN[CONF_STATION], ) entry.add_to_hass(hass) with patch("homeassistant.components.pegel_online.PegelOnline") as pegelonline: pegelonline.return_value = PegelOnlineMock( - station_details=MOCK_STATION_DETAILS, - station_measurement=MOCK_STATION_MEASUREMENT, + station_details=MOCK_STATION_DETAILS_DRESDEN, + station_measurements=MOCK_STATION_MEASUREMENT_DRESDEN, ) assert await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/pegel_online/test_sensor.py b/tests/components/pegel_online/test_sensor.py index 216ca3427c5..a02c7538280 100644 --- a/tests/components/pegel_online/test_sensor.py +++ b/tests/components/pegel_online/test_sensor.py @@ -1,53 +1,141 @@ """Test pegel_online component.""" from unittest.mock import patch -from aiopegelonline import CurrentMeasurement, Station +from aiopegelonline.models import Station, StationMeasurements +import pytest from homeassistant.components.pegel_online.const import CONF_STATION, DOMAIN -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant from . import PegelOnlineMock +from .const import ( + MOCK_CONFIG_ENTRY_DATA_DRESDEN, + MOCK_CONFIG_ENTRY_DATA_HANAU_BRIDGE, + MOCK_CONFIG_ENTRY_DATA_WUERZBURG, + MOCK_STATION_DETAILS_DRESDEN, + MOCK_STATION_DETAILS_HANAU_BRIDGE, + MOCK_STATION_DETAILS_WUERZBURG, + MOCK_STATION_MEASUREMENT_DRESDEN, + MOCK_STATION_MEASUREMENT_HANAU_BRIDGE, + MOCK_STATION_MEASUREMENT_WUERZBURG, +) from tests.common import MockConfigEntry -MOCK_CONFIG_ENTRY_DATA = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} -MOCK_STATION_DETAILS = Station( - { - "uuid": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", - "number": "501060", - "shortname": "DRESDEN", - "longname": "DRESDEN", - "km": 55.63, - "agency": "STANDORT DRESDEN", - "longitude": 13.738831783620384, - "latitude": 51.054459765598125, - "water": {"shortname": "ELBE", "longname": "ELBE"}, - } +@pytest.mark.parametrize( + ( + "mock_config_entry_data", + "mock_station_details", + "mock_station_measurement", + "expected_states", + ), + [ + ( + MOCK_CONFIG_ENTRY_DATA_DRESDEN, + MOCK_STATION_DETAILS_DRESDEN, + MOCK_STATION_MEASUREMENT_DRESDEN, + { + "sensor.dresden_elbe_water_volume_flow": ( + "DRESDEN ELBE Water volume flow", + "88.4", + "m³/s", + ), + "sensor.dresden_elbe_water_level": ( + "DRESDEN ELBE Water level", + "62", + "cm", + ), + }, + ), + ( + MOCK_CONFIG_ENTRY_DATA_HANAU_BRIDGE, + MOCK_STATION_DETAILS_HANAU_BRIDGE, + MOCK_STATION_MEASUREMENT_HANAU_BRIDGE, + { + "sensor.hanau_brucke_dfh_main_clearance_height": ( + "HANAU BRÜCKE DFH MAIN Clearance height", + "715", + "cm", + ), + }, + ), + ( + MOCK_CONFIG_ENTRY_DATA_WUERZBURG, + MOCK_STATION_DETAILS_WUERZBURG, + MOCK_STATION_MEASUREMENT_WUERZBURG, + { + "sensor.wurzburg_main_air_temperature": ( + "WÜRZBURG MAIN Air temperature", + "21.2", + "°C", + ), + "sensor.wurzburg_main_oxygen_level": ( + "WÜRZBURG MAIN Oxygen level", + "8.4", + "mg/l", + ), + "sensor.wurzburg_main_ph": ( + "WÜRZBURG MAIN pH", + "8.1", + None, + ), + "sensor.wurzburg_main_water_flow_speed": ( + "WÜRZBURG MAIN Water flow speed", + "0.58", + "m/s", + ), + "sensor.wurzburg_main_water_volume_flow": ( + "WÜRZBURG MAIN Water volume flow", + "102", + "m³/s", + ), + "sensor.wurzburg_main_water_level": ( + "WÜRZBURG MAIN Water level", + "159", + "cm", + ), + "sensor.wurzburg_main_water_temperature": ( + "WÜRZBURG MAIN Water temperature", + "22.1", + "°C", + ), + }, + ), + ], ) -MOCK_STATION_MEASUREMENT = CurrentMeasurement("cm", 56) - - -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor( + hass: HomeAssistant, + mock_config_entry_data: dict, + mock_station_details: Station, + mock_station_measurement: StationMeasurements, + expected_states: dict, + entity_registry_enabled_by_default: None, +) -> None: """Tests sensor entity.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_CONFIG_ENTRY_DATA, - unique_id=MOCK_CONFIG_ENTRY_DATA[CONF_STATION], + data=mock_config_entry_data, + unique_id=mock_config_entry_data[CONF_STATION], ) entry.add_to_hass(hass) with patch("homeassistant.components.pegel_online.PegelOnline") as pegelonline: pegelonline.return_value = PegelOnlineMock( - station_details=MOCK_STATION_DETAILS, - station_measurement=MOCK_STATION_MEASUREMENT, + station_details=mock_station_details, + station_measurements=mock_station_measurement, ) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.dresden_elbe_water_level") - assert state.name == "DRESDEN ELBE Water level" - assert state.state == "56" - assert state.attributes[ATTR_LATITUDE] == 51.054459765598125 - assert state.attributes[ATTR_LONGITUDE] == 13.738831783620384 + assert len(hass.states.async_all()) == len(expected_states) + + for state_name, state_data in expected_states.items(): + state = hass.states.get(state_name) + assert state.name == state_data[0] + assert state.state == state_data[1] + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == state_data[2] + if mock_station_details.latitude is not None: + assert state.attributes[ATTR_LATITUDE] == mock_station_details.latitude + assert state.attributes[ATTR_LONGITUDE] == mock_station_details.longitude diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py index 897bc5ebc70..0e8427b29e5 100644 --- a/tests/components/philips_js/test_device_trigger.py +++ b/tests/components/philips_js/test_device_trigger.py @@ -8,10 +8,7 @@ from homeassistant.components.philips_js.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import ( - async_get_device_automations, - async_mock_service, -) +from tests.common import async_get_device_automations, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") diff --git a/tests/components/plex/test_device_handling.py b/tests/components/plex/test_device_handling.py index 6abdc8cbeca..5887079ce21 100644 --- a/tests/components/plex/test_device_handling.py +++ b/tests/components/plex/test_device_handling.py @@ -15,6 +15,7 @@ async def test_cleanup_orphaned_devices( device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) + entry.add_to_hass(hass) test_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -55,6 +56,7 @@ async def test_migrate_transient_devices( device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) + entry.add_to_hass(hass) # Pre-create devices and entities to test device migration plexweb_device = device_registry.async_get_or_create( diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index ae396388639..a97d312cd54 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -6,9 +6,10 @@ import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from plugwise import PlugwiseData import pytest -from homeassistant.components.plugwise.const import API, DOMAIN, PW_TYPE +from homeassistant.components.plugwise.const import DOMAIN from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -39,7 +40,6 @@ def mock_config_entry() -> MockConfigEntry: CONF_PASSWORD: "test-password", CONF_PORT: 80, CONF_USERNAME: "smile", - PW_TYPE: API, }, unique_id="smile98765", ) @@ -90,7 +90,10 @@ def mock_smile_adam() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -116,7 +119,10 @@ def mock_smile_adam_2() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -142,7 +148,39 @@ def mock_smile_adam_3() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) + + yield smile + + +@pytest.fixture +def mock_smile_adam_4() -> Generator[None, MagicMock, None]: + """Create a 4th Mock Adam environment for testing exceptions.""" + chosen_env = "adam_jip" + + with patch( + "homeassistant.components.plugwise.coordinator.Smile", autospec=True + ) as smile_mock: + smile = smile_mock.return_value + + smile.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" + smile.heater_id = "e4684553153b44afbef2200885f379dc" + smile.smile_version = "3.2.8" + smile.smile_type = "thermostat" + smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" + smile.smile_name = "Adam" + + smile.connect.return_value = True + + smile.notifications = _read_json(chosen_env, "notifications") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -167,7 +205,10 @@ def mock_smile_anna() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -192,7 +233,10 @@ def mock_smile_anna_2() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -217,7 +261,10 @@ def mock_smile_anna_3() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -242,7 +289,10 @@ def mock_smile_p1() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -250,7 +300,7 @@ def mock_smile_p1() -> Generator[None, MagicMock, None]: @pytest.fixture def mock_smile_p1_2() -> Generator[None, MagicMock, None]: """Create a Mock P1 3-phase DSMR environment for testing exceptions.""" - chosen_env = "p1v4_3ph" + chosen_env = "p1v4_442_triple" with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: @@ -267,7 +317,10 @@ def mock_smile_p1_2() -> Generator[None, MagicMock, None]: smile.connect.return_value = True smile.notifications = _read_json(chosen_env, "notifications") - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile @@ -290,7 +343,10 @@ def mock_stretch() -> Generator[None, MagicMock, None]: smile.smile_name = "Stretch" smile.connect.return_value = True - smile.async_update.return_value = _read_json(chosen_env, "all_data") + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) yield smile diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json new file mode 100644 index 00000000000..177478f0fff --- /dev/null +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -0,0 +1,266 @@ +{ + "devices": { + "1346fbd8498d4dbcab7e18d51b771f3d": { + "active_preset": "no_frost", + "available": true, + "available_schedules": ["None"], + "control_state": "off", + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": null, + "location": "06aecb3d00354375924f50c47af36bd2", + "mode": "heat", + "model": "Lisa", + "name": "Slaapkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": { + "battery": 92, + "setpoint": 13.0, + "temperature": 24.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "1da4d325838e4ad8aac12177214505c9": { + "available": true, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "model": "Tom/Floor", + "name": "Tom Logeerkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.8, + "temperature_difference": 2.0, + "valve_position": 0.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "356b65335e274d769c338223e7af9c33": { + "available": true, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "06aecb3d00354375924f50c47af36bd2", + "model": "Tom/Floor", + "name": "Tom Slaapkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 24.3, + "temperature_difference": 1.7, + "valve_position": 0.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "457ce8414de24596a2d5e7dbc9c7682f": { + "available": true, + "dev_class": "zz_misc", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "model": "lumi.plug.maeu01", + "name": "Plug", + "sensors": { + "electricity_consumed_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": false + }, + "vendor": "LUMI", + "zigbee_mac_address": "ABCD012345670A06" + }, + "6f3e9d7084214c21b9dfa46f6eeb8700": { + "active_preset": "home", + "available": true, + "available_schedules": ["None"], + "control_state": "off", + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": null, + "location": "d27aede973b54be484f6842d1b2802ad", + "mode": "heat", + "model": "Lisa", + "name": "Kinderkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": { + "battery": 79, + "setpoint": 13.0, + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "833de10f269c4deab58fb9df69901b4e": { + "available": true, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "13228dab8ce04617af318a2888b3c548", + "model": "Tom/Floor", + "name": "Tom Woonkamer", + "sensors": { + "setpoint": 9.0, + "temperature": 24.0, + "temperature_difference": 1.8, + "valve_position": 100 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "a6abc6a129ee499c88a4d420cc413b47": { + "active_preset": "home", + "available": true, + "available_schedules": ["None"], + "control_state": "off", + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": null, + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "mode": "heat", + "model": "Lisa", + "name": "Logeerkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": { + "battery": 80, + "setpoint": 13.0, + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "b5c2386c6f6342669e50fe49dd05b188": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.2.8", + "hardware": "AME Smile 2.0 board", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "mac_address": "012345670001", + "model": "Gateway", + "name": "Adam", + "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 24.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "d4496250d0e942cfa7aea3476e9070d5": { + "available": true, + "dev_class": "thermo_sensor", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d27aede973b54be484f6842d1b2802ad", + "model": "Tom/Floor", + "name": "Tom Kinderkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.7, + "temperature_difference": 1.9, + "valve_position": 0.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + }, + "e4684553153b44afbef2200885f379dc": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 20.0, + "resolution": 0.01, + "setpoint": 90.0, + "upper_bound": 90.0 + }, + "model": "10.20", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 0.0, + "modulation_level": 0.0, + "return_temperature": 37.1, + "water_pressure": 1.4, + "water_temperature": 37.3 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Remeha B.V." + }, + "f61f1a2535f54f52ad006a3d18e459ca": { + "active_preset": "home", + "available": true, + "available_schedules": ["None"], + "control_state": "off", + "dev_class": "zone_thermometer", + "firmware": "2020-09-01T02:00:00+02:00", + "hardware": "1", + "last_used": null, + "location": "13228dab8ce04617af318a2888b3c548", + "mode": "heat", + "model": "Jip", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": { + "battery": 100, + "humidity": 56.2, + "setpoint": 9.0, + "temperature": 27.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.01, + "setpoint": 9.0, + "upper_bound": 30.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + } + }, + "gateway": { + "cooling_present": false, + "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", + "heater_id": "e4684553153b44afbef2200885f379dc", + "notifications": {}, + "smile_name": "Adam" + } +} diff --git a/tests/components/plugwise/fixtures/p1v4_3ph/notifications.json b/tests/components/plugwise/fixtures/adam_jip/notifications.json similarity index 100% rename from tests/components/plugwise/fixtures/p1v4_3ph/notifications.json rename to tests/components/plugwise/fixtures/adam_jip/notifications.json diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index d62ff0e249d..63f0012ea92 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -1,164 +1,32 @@ -[ - { - "smile_name": "Adam", - "gateway_id": "fe799307f1624099878210aa0b9f1475", - "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "cooling_present": false, - "notifications": { - "af82e4ccf9c548528166d38e560662a4": { - "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." - } - } - }, - { - "df4a4a8169904cdb9c03d61a21f42140": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Lisa", - "name": "Zone Lisa Bios", - "zigbee_mac_address": "ABCD012345670A06", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 13.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01 +{ + "devices": { + "02cf28bfec924855854c544690a609ef": { + "available": true, + "dev_class": "vcr", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "NVR", + "sensors": { + "electricity_consumed": 34.0, + "electricity_consumed_interval": 9.15, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 }, - "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie" - ], - "selected_schedule": "None", - "last_used": "Badkamer Schema", - "mode": "heat", - "sensors": { - "temperature": 16.5, - "setpoint": 13.0, - "battery": 67 - } - }, - "b310b72a0e354bfab43089919b9a88bf": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "name": "Floor kraan", - "zigbee_mac_address": "ABCD012345670A02", - "vendor": "Plugwise", - "available": true, - "sensors": { - "temperature": 26.0, - "setpoint": 21.5, - "temperature_difference": 3.5, - "valve_position": 100 - } - }, - "a2c3583e0a6349358998b760cea82d2a": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Tom/Floor", - "name": "Bios Cv Thermostatic Radiator ", - "zigbee_mac_address": "ABCD012345670A09", - "vendor": "Plugwise", - "available": true, - "sensors": { - "temperature": 17.2, - "setpoint": 13.0, - "battery": 62, - "temperature_difference": -0.2, - "valve_position": 0.0 - } - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Lisa", - "name": "Zone Lisa WK", - "zigbee_mac_address": "ABCD012345670A07", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 21.5, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01 + "switches": { + "lock": true, + "relay": true }, - "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "home", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie" - ], - "selected_schedule": "GF7 Woonkamer", - "last_used": "GF7 Woonkamer", - "mode": "auto", - "sensors": { - "temperature": 20.9, - "setpoint": 21.5, - "battery": 34 - } - }, - "fe799307f1624099878210aa0b9f1475": { - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "name": "Adam", - "zigbee_mac_address": "ABCD012345670101", "vendor": "Plugwise", - "regulation_mode": "heating", - "binary_sensors": { - "plugwise_notification": true - }, - "sensors": { - "outdoor_temperature": 7.81 - } - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Jessie", - "zigbee_mac_address": "ABCD012345670A10", - "vendor": "Plugwise", - "available": true, - "sensors": { - "temperature": 17.1, - "setpoint": 15.0, - "battery": 62, - "temperature_difference": 0.1, - "valve_position": 0.0 - } + "zigbee_mac_address": "ABCD012345670A15" }, "21f2b542c49845e6bb416884c55778d6": { + "available": true, "dev_class": "game_console", "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", "name": "Playstation Smart Plug", - "zigbee_mac_address": "ABCD012345670A12", - "vendor": "Plugwise", - "available": true, "sensors": { "electricity_consumed": 82.6, "electricity_consumed_interval": 8.6, @@ -166,19 +34,111 @@ "electricity_produced_interval": 0.0 }, "switches": { - "relay": true, - "lock": false - } + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A12" + }, + "4a810418d5394b3f82727340b91ba740": { + "available": true, + "dev_class": "router", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "USG Smart Plug", + "sensors": { + "electricity_consumed": 8.5, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A16" + }, + "675416a629f343c495449970e2ca37b5": { + "available": true, + "dev_class": "router", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "Ziggo Modem", + "sensors": { + "electricity_consumed": 12.2, + "electricity_consumed_interval": 2.97, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "680423ff840043738f42cc7f1ff97a36": { + "available": true, + "dev_class": "thermo_sensor", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Tom/Floor", + "name": "Thermostatic Radiator Badkamer", + "sensors": { + "battery": 51, + "setpoint": 14.0, + "temperature": 19.1, + "temperature_difference": -0.4, + "valve_position": 0.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A17" + }, + "6a3bf693d05e48e0b460c815a4fdd09d": { + "active_preset": "asleep", + "available": true, + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie" + ], + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": "CV Jessie", + "location": "82fa13f017d240daa0d0ea1775420f24", + "mode": "auto", + "model": "Lisa", + "name": "Zone Thermostat Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": { + "battery": 37, + "setpoint": 15.0, + "temperature": 17.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" }, "78d1126fc4c743db81b61c20e88342a7": { + "available": true, "dev_class": "central_heating_pump", "firmware": "2019-06-21T02:00:00+02:00", "location": "c50f167537524366a5af7aa3942feb1e", "model": "Plug", "name": "CV Pomp", - "zigbee_mac_address": "ABCD012345670A05", - "vendor": "Plugwise", - "available": true, "sensors": { "electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, @@ -187,91 +147,31 @@ }, "switches": { "relay": true - } + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" }, "90986d591dcd426cae3ec3e8111ff730": { + "binary_sensors": { + "heating_state": true + }, "dev_class": "heater_central", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "model": "Unknown", "name": "OnOff", - "binary_sensors": { - "heating_state": true - }, "sensors": { - "water_temperature": 70.0, "intended_boiler_temperature": 70.0, - "modulation_level": 1 - } - }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NAS", - "zigbee_mac_address": "ABCD012345670A14", - "vendor": "Plugwise", - "available": true, - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "relay": true, - "lock": true - } - }, - "4a810418d5394b3f82727340b91ba740": { - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "USG Smart Plug", - "zigbee_mac_address": "ABCD012345670A16", - "vendor": "Plugwise", - "available": true, - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "relay": true, - "lock": true - } - }, - "02cf28bfec924855854c544690a609ef": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NVR", - "zigbee_mac_address": "ABCD012345670A15", - "vendor": "Plugwise", - "available": true, - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "relay": true, - "lock": true + "modulation_level": 1, + "water_temperature": 70.0 } }, "a28f588dc4a049a483fd03a30361ad3a": { + "available": true, "dev_class": "settop", "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", "name": "Fibaro HC2", - "zigbee_mac_address": "ABCD012345670A13", - "vendor": "Plugwise", - "available": true, "sensors": { "electricity_consumed": 12.5, "electricity_consumed_interval": 3.8, @@ -279,80 +179,50 @@ "electricity_produced_interval": 0.0 }, "switches": { - "relay": true, - "lock": true - } - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Lisa", - "name": "Zone Thermostat Jessie", - "zigbee_mac_address": "ABCD012345670A03", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 15.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01 + "lock": true, + "relay": true }, - "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "asleep", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie" - ], - "selected_schedule": "CV Jessie", - "last_used": "CV Jessie", - "mode": "auto", - "sensors": { - "temperature": 17.2, - "setpoint": 15.0, - "battery": 37 - } + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A13" }, - "680423ff840043738f42cc7f1ff97a36": { + "a2c3583e0a6349358998b760cea82d2a": { + "available": true, "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", + "location": "12493538af164a409c6a1c79e38afe1c", "model": "Tom/Floor", - "name": "Thermostatic Radiator Badkamer", - "zigbee_mac_address": "ABCD012345670A17", - "vendor": "Plugwise", - "available": true, + "name": "Bios Cv Thermostatic Radiator ", "sensors": { - "temperature": 19.1, - "setpoint": 14.0, - "battery": 51, - "temperature_difference": -0.4, + "battery": 62, + "setpoint": 13.0, + "temperature": 17.2, + "temperature_difference": -0.2, "valve_position": 0.0 - } - }, - "f1fee6043d3642a9b0a65297455f008e": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Lisa", - "name": "Zone Thermostat Badkamer", - "zigbee_mac_address": "ABCD012345670A08", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 14.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01 }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "b310b72a0e354bfab43089919b9a88bf": { + "available": true, + "dev_class": "thermo_sensor", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Tom/Floor", + "name": "Floor kraan", + "sensors": { + "setpoint": 21.5, + "temperature": 26.0, + "temperature_difference": 3.5, + "valve_position": 100 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "b59bcebaf94b499ea7d46e4a66fb62d8": { + "active_preset": "home", "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -360,53 +230,71 @@ "Badkamer Schema", "CV Jessie" ], - "selected_schedule": "Badkamer Schema", - "last_used": "Badkamer Schema", + "dev_class": "zone_thermostat", + "firmware": "2016-08-02T02:00:00+02:00", + "hardware": "255", + "last_used": "GF7 Woonkamer", + "location": "c50f167537524366a5af7aa3942feb1e", "mode": "auto", + "model": "Lisa", + "name": "Zone Lisa WK", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", "sensors": { - "temperature": 18.9, - "setpoint": 14.0, - "battery": 92 - } + "battery": 34, + "setpoint": 21.5, + "temperature": 20.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 21.5, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" }, - "675416a629f343c495449970e2ca37b5": { - "dev_class": "router", + "cd0ddb54ef694e11ac18ed1cbce5dbbd": { + "available": true, + "dev_class": "vcr", "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", - "name": "Ziggo Modem", - "zigbee_mac_address": "ABCD012345670A01", - "vendor": "Plugwise", - "available": true, + "name": "NAS", "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, + "electricity_consumed": 16.5, + "electricity_consumed_interval": 0.5, "electricity_produced": 0.0, "electricity_produced_interval": 0.0 }, "switches": { - "relay": true, - "lock": true - } + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A14" }, - "e7693eb9582644e5b865dba8d4447cf1": { - "dev_class": "thermostatic_radiator_valve", + "d3da73bde12a47d5a6b8f9dad971f2ec": { + "available": true, + "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "location": "446ac08dd04d4eff8ac57489757b7314", + "location": "82fa13f017d240daa0d0ea1775420f24", "model": "Tom/Floor", - "name": "CV Kraan Garage", - "zigbee_mac_address": "ABCD012345670A11", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 5.5, - "lower_bound": 0.0, - "upper_bound": 100.0, - "resolution": 0.01 + "name": "Thermostatic Radiator Jessie", + "sensors": { + "battery": 62, + "setpoint": 15.0, + "temperature": 17.1, + "temperature_difference": 0.1, + "valve_position": 0.0 }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A10" + }, + "df4a4a8169904cdb9c03d61a21f42140": { + "active_preset": "away", "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "no_frost", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -414,16 +302,128 @@ "Badkamer Schema", "CV Jessie" ], - "selected_schedule": "None", + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", "last_used": "Badkamer Schema", + "location": "12493538af164a409c6a1c79e38afe1c", "mode": "heat", + "model": "Lisa", + "name": "Zone Lisa Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": { + "battery": 67, + "setpoint": 13.0, + "temperature": 16.5 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A06" + }, + "e7693eb9582644e5b865dba8d4447cf1": { + "active_preset": "no_frost", + "available": true, + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie" + ], + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "last_used": "Badkamer Schema", + "location": "446ac08dd04d4eff8ac57489757b7314", + "mode": "heat", + "model": "Tom/Floor", + "name": "CV Kraan Garage", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", "sensors": { - "temperature": 15.6, - "setpoint": 5.5, "battery": 68, + "setpoint": 5.5, + "temperature": 15.6, "temperature_difference": 0.0, "valve_position": 0.0 - } + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A11" + }, + "f1fee6043d3642a9b0a65297455f008e": { + "active_preset": "away", + "available": true, + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie" + ], + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": "Badkamer Schema", + "location": "08963fec7c53423ca5680aa4cb502c63", + "mode": "auto", + "model": "Lisa", + "name": "Zone Thermostat Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": { + "battery": 92, + "setpoint": 14.0, + "temperature": 18.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + }, + "fe799307f1624099878210aa0b9f1475": { + "binary_sensors": { + "plugwise_notification": true + }, + "dev_class": "gateway", + "firmware": "3.0.15", + "hardware": "AME Smile 2.0 board", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "mac_address": "012345670001", + "model": "Gateway", + "name": "Adam", + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 7.81 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" } + }, + "gateway": { + "cooling_present": false, + "gateway_id": "fe799307f1624099878210aa0b9f1475", + "heater_id": "90986d591dcd426cae3ec3e8111ff730", + "notifications": { + "af82e4ccf9c548528166d38e560662a4": { + "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." + } + }, + "smile_name": "Adam" } -] +} diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index f00293a6554..49b5221233f 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -1,48 +1,9 @@ -[ - { - "smile_name": "Smile Anna", - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "cooling_present": false, - "notifications": {} - }, - { - "1cbf783bb11e4a7c8a6843dee3a86927": { - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "model": "Generic heater/cooler", - "name": "OpenTherm", - "vendor": "Techneco", - "maximum_boiler_temperature": { - "setpoint": 60.0, - "lower_bound": 0.0, - "upper_bound": 100.0, - "resolution": 1.0 - }, - "available": true, - "binary_sensors": { - "cooling_enabled": false, - "dhw_state": false, - "heating_state": true, - "compressor_state": true, - "slave_boiler_state": false, - "flame_state": false - }, - "sensors": { - "water_temperature": 29.1, - "domestic_hot_water_setpoint": 60.0, - "dhw_temperature": 46.3, - "intended_boiler_temperature": 35.0, - "modulation_level": 52, - "return_temperature": 25.1, - "water_pressure": 1.57, - "outdoor_air_temperature": 3.0 - }, - "switches": { - "dhw_cm_switch": false - } - }, +{ + "devices": { "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, "dev_class": "gateway", "firmware": "4.0.15", "hardware": "AME Smile 2.0 board", @@ -50,41 +11,85 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Smile Anna", - "vendor": "Plugwise", - "binary_sensors": { - "plugwise_notification": false - }, "sensors": { "outdoor_temperature": 20.2 - } + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": false, + "dhw_state": false, + "flame_state": false, + "heating_state": true, + "slave_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 35.0, + "modulation_level": 52, + "outdoor_air_temperature": 3.0, + "return_temperature": 25.1, + "water_pressure": 1.57, + "water_temperature": 29.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" }, "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", + "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "mode": "auto", "model": "ThermoTouch", "name": "Anna", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 20.5, - "lower_bound": 4.0, - "upper_bound": 30.0, - "resolution": 0.1 - }, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "active_preset": "home", - "available_schedules": ["standaard"], - "selected_schedule": "standaard", - "last_used": "standaard", - "mode": "auto", + "select_schedule": "standaard", "sensors": { - "temperature": 19.3, - "setpoint": 20.5, - "illuminance": 86.0, "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0 - } + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint": 20.5, + "temperature": 19.3 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" } + }, + "gateway": { + "cooling_present": false, + "gateway_id": "015ae9ea3f964e668e490fa39da3870b", + "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "notifications": {}, + "smile_name": "Smile Anna" } -] +} diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 06a3fa400bf..92618a90189 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -1,88 +1,80 @@ -[ - { - "smile_name": "Adam", - "gateway_id": "da224107914542988a88561b4452b0f6", - "heater_id": "056ee145a816487eaa69243c3280f8bf", - "cooling_present": true, - "notifications": {} - }, - { - "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "dev_class": "thermostat", - "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "ThermoTouch", - "name": "Anna", - "vendor": "Plugwise", - "thermostat": { - "setpoint_low": 4.0, - "setpoint_high": 23.5, - "lower_bound": 1.0, - "upper_bound": 35.0, - "resolution": 0.01 - }, +{ + "devices": { + "056ee145a816487eaa69243c3280f8bf": { "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "asleep", - "available_schedules": ["Weekschema", "Badkamer", "Test"], - "selected_schedule": "None", - "last_used": "Weekschema", - "control_state": "cooling", - "mode": "heat_cool", + "binary_sensors": { + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", "sensors": { - "temperature": 25.8, - "setpoint_low": 4.0, - "setpoint_high": 23.5 + "intended_boiler_temperature": 17.5, + "water_temperature": 19.0 + }, + "switches": { + "dhw_cm_switch": false } }, "1772a4ea304041adb83f357b751341ff": { + "available": true, "dev_class": "thermo_sensor", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", "model": "Tom/Floor", "name": "Tom Badkamer", - "zigbee_mac_address": "ABCD012345670A01", - "vendor": "Plugwise", - "available": true, "sensors": { - "temperature": 21.6, "battery": 99, + "temperature": 21.6, "temperature_difference": 2.3, "valve_position": 0.0 - } - }, - "e2f4322d57924fa090fbbc48b3a140dc": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-10T02:00:00+02:00", - "hardware": "255", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Lisa", - "name": "Lisa Badkamer", - "zigbee_mac_address": "ABCD012345670A04", - "vendor": "Plugwise", - "thermostat": { - "setpoint_low": 19.0, - "setpoint_high": 25.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01 }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "active_preset": "asleep", "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "home", "available_schedules": ["Weekschema", "Badkamer", "Test"], - "selected_schedule": "Badkamer", - "last_used": "Badkamer", - "control_state": "off", - "mode": "auto", + "control_state": "cooling", + "dev_class": "thermostat", + "last_used": "Weekschema", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "mode": "heat_cool", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Weekschema", + "selected_schedule": "None", "sensors": { - "temperature": 239, - "battery": 56, - "setpoint_low": 20.0, - "setpoint_high": 23.5 - } + "setpoint_high": 23.5, + "setpoint_low": 4.0, + "temperature": 25.8 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint_high": 23.5, + "setpoint_low": 4.0, + "upper_bound": 35.0 + }, + "vendor": "Plugwise" }, "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, "dev_class": "gateway", "firmware": "3.6.4", "hardware": "AME Smile 2.0 board", @@ -90,60 +82,70 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Adam", - "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise", "regulation_mode": "cooling", "regulation_modes": [ - "cooling", "heating", "off", "bleeding_cold", - "bleeding_hot" + "bleeding_hot", + "cooling" ], - "binary_sensors": { - "plugwise_notification": false - }, + "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": 29.65 - } + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" }, - "056ee145a816487eaa69243c3280f8bf": { - "dev_class": "heater_central", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "model": "Generic heater", - "name": "OpenTherm", - "maximum_boiler_temperature": { - "setpoint": 60.0, - "lower_bound": 25.0, - "upper_bound": 95.0, - "resolution": 0.01 - }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "active_preset": "home", "available": true, - "binary_sensors": { - "cooling_state": true, - "dhw_state": false, - "heating_state": false, - "flame_state": false - }, + "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "last_used": "Badkamer", + "location": "f871b8c4d63549319221e294e4f88074", + "mode": "auto", + "model": "Lisa", + "name": "Lisa Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer", "sensors": { - "water_temperature": 19.0, - "intended_boiler_temperature": 17.5 + "battery": 56, + "setpoint_high": 23.5, + "setpoint_low": 20.0, + "temperature": 239 }, - "switches": { - "dhw_cm_switch": false - } + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint_high": 25.0, + "setpoint_low": 19.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" }, "e8ef2a01ed3b4139a53bf749204fe6b4": { "dev_class": "switching", - "model": "Switchgroup", - "name": "Test", "members": [ "2568cc4b9c1e401495d4741a5f89bee1", "29542b2b6a6a4169acecc15c72a599b8" ], + "model": "Switchgroup", + "name": "Test", "switches": { "relay": true } } + }, + "gateway": { + "cooling_present": true, + "gateway_id": "da224107914542988a88561b4452b0f6", + "heater_id": "056ee145a816487eaa69243c3280f8bf", + "notifications": {}, + "smile_name": "Adam" } -] +} 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 8ee3df544e5..4345cf76a3a 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -1,81 +1,83 @@ -[ - { - "smile_name": "Adam", - "gateway_id": "da224107914542988a88561b4452b0f6", - "heater_id": "056ee145a816487eaa69243c3280f8bf", - "cooling_present": false, - "notifications": {} - }, - { - "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "dev_class": "thermostat", - "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "ThermoTouch", - "name": "Anna", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 20.0, - "lower_bound": 1.0, - "upper_bound": 35.0, - "resolution": 0.01 - }, +{ + "devices": { + "056ee145a816487eaa69243c3280f8bf": { "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "asleep", - "available_schedules": ["Weekschema", "Badkamer", "Test"], - "selected_schedule": "None", - "last_used": "Weekschema", - "control_state": "heating", - "mode": "heat", - "sensors": { "temperature": 19.1, "setpoint": 20.0 } + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 38.1, + "water_temperature": 37.0 + }, + "switches": { + "dhw_cm_switch": false + } }, "1772a4ea304041adb83f357b751341ff": { + "available": true, "dev_class": "thermo_sensor", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", "model": "Tom/Floor", "name": "Tom Badkamer", - "zigbee_mac_address": "ABCD012345670A01", - "vendor": "Plugwise", - "available": true, "sensors": { - "temperature": 18.6, "battery": 99, + "temperature": 18.6, "temperature_difference": 2.3, "valve_position": 0.0 - } - }, - "e2f4322d57924fa090fbbc48b3a140dc": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-10T02:00:00+02:00", - "hardware": "255", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Lisa", - "name": "Lisa Badkamer", - "zigbee_mac_address": "ABCD012345670A04", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 15.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01 }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "active_preset": "asleep", "available": true, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "home", "available_schedules": ["Weekschema", "Badkamer", "Test"], - "selected_schedule": "Badkamer", - "last_used": "Badkamer", - "control_state": "off", - "mode": "auto", + "control_state": "heating", + "dev_class": "thermostat", + "last_used": "Weekschema", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "mode": "heat", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Weekschema", + "selected_schedule": "None", "sensors": { - "temperature": 17.9, - "battery": 56, - "setpoint": 15.0 - } + "setpoint": 20.0, + "temperature": 19.1 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 20.0, + "upper_bound": 35.0 + }, + "vendor": "Plugwise" }, "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, "dev_class": "gateway", "firmware": "3.6.4", "hardware": "AME Smile 2.0 board", @@ -83,59 +85,62 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Adam", - "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise", "regulation_mode": "heating", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], - "binary_sensors": { - "plugwise_notification": false - }, + "select_regulation_mode": "heating", "sensors": { "outdoor_temperature": -1.25 - } + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" }, - "056ee145a816487eaa69243c3280f8bf": { - "dev_class": "heater_central", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "model": "Generic heater", - "name": "OpenTherm", - "maximum_boiler_temperature": { - "setpoint": 60.0, - "lower_bound": 25.0, - "upper_bound": 95.0, - "resolution": 0.01 - }, - "domestic_hot_water_setpoint": { - "setpoint": 60.0, - "lower_bound": 40.0, - "upper_bound": 60.0, - "resolution": 0.01 - }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "active_preset": "home", "available": true, - "binary_sensors": { - "dhw_state": false, - "heating_state": true, - "flame_state": false - }, + "available_schedules": ["Weekschema", "Badkamer", "Test"], + "control_state": "off", + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "last_used": "Badkamer", + "location": "f871b8c4d63549319221e294e4f88074", + "mode": "auto", + "model": "Lisa", + "name": "Lisa Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer", "sensors": { - "water_temperature": 37.0, - "intended_boiler_temperature": 38.1 + "battery": 56, + "setpoint": 15.0, + "temperature": 17.9 }, - "switches": { - "dhw_cm_switch": false - } + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" }, "e8ef2a01ed3b4139a53bf749204fe6b4": { "dev_class": "switching", - "model": "Switchgroup", - "name": "Test", "members": [ "2568cc4b9c1e401495d4741a5f89bee1", "29542b2b6a6a4169acecc15c72a599b8" ], + "model": "Switchgroup", + "name": "Test", "switches": { "relay": true } } + }, + "gateway": { + "cooling_present": false, + "gateway_id": "da224107914542988a88561b4452b0f6", + "heater_id": "056ee145a816487eaa69243c3280f8bf", + "notifications": {}, + "smile_name": "Adam" } -] +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index ba980a7fce3..20f2db213bd 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -1,49 +1,9 @@ -[ - { - "smile_name": "Smile Anna", - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "cooling_present": true, - "notifications": {} - }, - { - "1cbf783bb11e4a7c8a6843dee3a86927": { - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "model": "Generic heater/cooler", - "name": "OpenTherm", - "vendor": "Techneco", - "maximum_boiler_temperature": { - "setpoint": 60.0, - "lower_bound": 0.0, - "upper_bound": 100.0, - "resolution": 1.0 - }, - "available": true, - "binary_sensors": { - "cooling_enabled": true, - "dhw_state": false, - "heating_state": false, - "compressor_state": true, - "cooling_state": true, - "slave_boiler_state": false, - "flame_state": false - }, - "sensors": { - "water_temperature": 22.7, - "domestic_hot_water_setpoint": 60.0, - "dhw_temperature": 41.5, - "intended_boiler_temperature": 0.0, - "modulation_level": 40, - "return_temperature": 23.8, - "water_pressure": 1.57, - "outdoor_air_temperature": 28.0 - }, - "switches": { - "dhw_cm_switch": false - } - }, +{ + "devices": { "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, "dev_class": "gateway", "firmware": "4.0.15", "hardware": "AME Smile 2.0 board", @@ -51,43 +11,88 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Smile Anna", - "vendor": "Plugwise", - "binary_sensors": { - "plugwise_notification": false - }, "sensors": { "outdoor_temperature": 28.2 - } + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": true, + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "slave_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 41.5, + "intended_boiler_temperature": 0.0, + "modulation_level": 40, + "outdoor_air_temperature": 28.0, + "return_temperature": 23.8, + "water_pressure": 1.57, + "water_temperature": 22.7 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" }, "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", + "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "mode": "auto", "model": "ThermoTouch", "name": "Anna", - "vendor": "Plugwise", - "thermostat": { - "setpoint_low": 20.5, - "setpoint_high": 24.0, - "lower_bound": 4.0, - "upper_bound": 30.0, - "resolution": 0.1 - }, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "active_preset": "home", - "available_schedules": ["standaard"], - "selected_schedule": "standaard", - "last_used": "standaard", - "mode": "auto", + "select_schedule": "standaard", "sensors": { - "temperature": 26.3, - "illuminance": 86.0, "cooling_activation_outdoor_temperature": 21.0, "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 24.0, "setpoint_low": 20.5, - "setpoint_high": 24.0 - } + "temperature": 26.3 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 24.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" } + }, + "gateway": { + "cooling_present": true, + "gateway_id": "015ae9ea3f964e668e490fa39da3870b", + "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "notifications": {}, + "smile_name": "Smile Anna" } -] +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 0a421be5343..3a7bd2dae89 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -1,48 +1,9 @@ -[ - { - "smile_name": "Smile Anna", - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "cooling_present": true, - "notifications": {} - }, - { - "1cbf783bb11e4a7c8a6843dee3a86927": { - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "model": "Generic heater/cooler", - "name": "OpenTherm", - "vendor": "Techneco", - "maximum_boiler_temperature": { - "setpoint": 60.0, - "lower_bound": 0.0, - "upper_bound": 100.0, - "resolution": 1.0 - }, - "available": true, - "binary_sensors": { - "cooling_enabled": true, - "dhw_state": false, - "heating_state": false, - "compressor_state": false, - "cooling_state": false, - "slave_boiler_state": false, - "flame_state": false - }, - "sensors": { - "water_temperature": 19.1, - "dhw_temperature": 46.3, - "intended_boiler_temperature": 18.0, - "modulation_level": 0, - "return_temperature": 22.0, - "water_pressure": 1.57, - "outdoor_air_temperature": 28.2 - }, - "switches": { - "dhw_cm_switch": false - } - }, +{ + "devices": { "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, "dev_class": "gateway", "firmware": "4.0.15", "hardware": "AME Smile 2.0 board", @@ -50,43 +11,88 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Smile Anna", - "vendor": "Plugwise", - "binary_sensors": { - "plugwise_notification": false - }, "sensors": { "outdoor_temperature": 28.2 - } + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": false, + "cooling_enabled": true, + "cooling_state": false, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "slave_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 18.0, + "modulation_level": 0, + "outdoor_air_temperature": 28.2, + "return_temperature": 22.0, + "water_pressure": 1.57, + "water_temperature": 19.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" }, "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard"], "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", + "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "mode": "auto", "model": "ThermoTouch", "name": "Anna", - "vendor": "Plugwise", - "thermostat": { - "setpoint_low": 20.5, - "setpoint_high": 24.0, - "lower_bound": 4.0, - "upper_bound": 30.0, - "resolution": 0.1 - }, "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "active_preset": "home", - "available_schedules": ["standaard"], - "selected_schedule": "standaard", - "last_used": "standaard", - "mode": "auto", + "select_schedule": "standaard", "sensors": { - "temperature": 23.0, - "illuminance": 86.0, "cooling_activation_outdoor_temperature": 25.0, "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 24.0, "setpoint_low": 20.5, - "setpoint_high": 24.0 - } + "temperature": 23.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 24.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" } + }, + "gateway": { + "cooling_present": true, + "gateway_id": "015ae9ea3f964e668e490fa39da3870b", + "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", + "notifications": {}, + "smile_name": "Smile Anna" } -] +} diff --git a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json index c52f33e6323..0e0b3c51a07 100644 --- a/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json +++ b/tests/components/plugwise/fixtures/p1v3_full_option/all_data.json @@ -1,11 +1,9 @@ -[ - { - "smile_name": "Smile P1", - "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", - "notifications": {} - }, - { +{ + "devices": { "cd3e822288064775a7c4afcdd70bdda2": { + "binary_sensors": { + "plugwise_notification": false + }, "dev_class": "gateway", "firmware": "3.3.9", "hardware": "AME Smile 2.0 board", @@ -13,36 +11,38 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Smile P1", - "vendor": "Plugwise", - "binary_sensors": { - "plugwise_notification": false - } + "vendor": "Plugwise" }, "e950c7d5e1ee407a858e2a8b5016c8b3": { + "available": true, "dev_class": "smartmeter", "location": "cd3e822288064775a7c4afcdd70bdda2", "model": "2M550E-1012", "name": "P1", - "vendor": "ISKRAEMECO", - "available": true, "sensors": { - "net_electricity_point": -2816, - "electricity_consumed_peak_point": 0, - "electricity_consumed_off_peak_point": 0, - "net_electricity_cumulative": 442.972, - "electricity_consumed_peak_cumulative": 442.932, "electricity_consumed_off_peak_cumulative": 551.09, - "electricity_consumed_peak_interval": 0, "electricity_consumed_off_peak_interval": 0, - "electricity_produced_peak_point": 2816, + "electricity_consumed_off_peak_point": 0, + "electricity_consumed_peak_cumulative": 442.932, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_produced_off_peak_cumulative": 154.491, + "electricity_produced_off_peak_interval": 0, "electricity_produced_off_peak_point": 0, "electricity_produced_peak_cumulative": 396.559, - "electricity_produced_off_peak_cumulative": 154.491, "electricity_produced_peak_interval": 0, - "electricity_produced_off_peak_interval": 0, + "electricity_produced_peak_point": 2816, "gas_consumed_cumulative": 584.85, - "gas_consumed_interval": 0.0 - } + "gas_consumed_interval": 0.0, + "net_electricity_cumulative": 442.972, + "net_electricity_point": -2816 + }, + "vendor": "ISKRAEMECO" } + }, + "gateway": { + "gateway_id": "cd3e822288064775a7c4afcdd70bdda2", + "notifications": {}, + "smile_name": "Smile P1" } -] +} diff --git a/tests/components/plugwise/fixtures/p1v4_3ph/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json similarity index 88% rename from tests/components/plugwise/fixtures/p1v4_3ph/all_data.json rename to tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index 852ca2857cd..e9a3b4c68b9 100644 --- a/tests/components/plugwise/fixtures/p1v4_3ph/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -1,11 +1,9 @@ -[ - { - "smile_name": "Smile P1", - "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", - "notifications": {} - }, - { +{ + "devices": { "03e65b16e4b247a29ae0d75a78cb492e": { + "binary_sensors": { + "plugwise_notification": false + }, "dev_class": "gateway", "firmware": "4.4.2", "hardware": "AME Smile 2.0 board", @@ -13,45 +11,47 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Smile P1", - "vendor": "Plugwise", - "binary_sensors": { - "plugwise_notification": false - } + "vendor": "Plugwise" }, "b82b6b3322484f2ea4e25e0bd5f3d61f": { + "available": true, "dev_class": "smartmeter", "location": "03e65b16e4b247a29ae0d75a78cb492e", "model": "XMX5LGF0010453051839", "name": "P1", - "vendor": "XEMEX NV", - "available": true, "sensors": { - "net_electricity_point": 5553, - "electricity_consumed_peak_point": 0, - "electricity_consumed_off_peak_point": 5553, - "net_electricity_cumulative": 231866.539, - "electricity_consumed_peak_cumulative": 161328.641, "electricity_consumed_off_peak_cumulative": 70537.898, - "electricity_consumed_peak_interval": 0, "electricity_consumed_off_peak_interval": 314, - "electricity_produced_peak_point": 0, + "electricity_consumed_off_peak_point": 5553, + "electricity_consumed_peak_cumulative": 161328.641, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 1763, + "electricity_phase_one_produced": 0, + "electricity_phase_three_consumed": 2080, + "electricity_phase_three_produced": 0, + "electricity_phase_two_consumed": 1703, + "electricity_phase_two_produced": 0, + "electricity_produced_off_peak_cumulative": 0.0, + "electricity_produced_off_peak_interval": 0, "electricity_produced_off_peak_point": 0, "electricity_produced_peak_cumulative": 0.0, - "electricity_produced_off_peak_cumulative": 0.0, "electricity_produced_peak_interval": 0, - "electricity_produced_off_peak_interval": 0, - "electricity_phase_one_consumed": 1763, - "electricity_phase_two_consumed": 1703, - "electricity_phase_three_consumed": 2080, - "electricity_phase_one_produced": 0, - "electricity_phase_two_produced": 0, - "electricity_phase_three_produced": 0, + "electricity_produced_peak_point": 0, "gas_consumed_cumulative": 16811.37, "gas_consumed_interval": 0.06, + "net_electricity_cumulative": 231866.539, + "net_electricity_point": 5553, "voltage_phase_one": 233.2, - "voltage_phase_two": 234.4, - "voltage_phase_three": 234.7 - } + "voltage_phase_three": 234.7, + "voltage_phase_two": 234.4 + }, + "vendor": "XEMEX NV" } + }, + "gateway": { + "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", + "notifications": {}, + "smile_name": "Smile P1" } -] +} diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json @@ -0,0 +1 @@ +{} diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index 1ce34e376d7..c336a9cb9c2 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -1,10 +1,5 @@ -[ - { - "smile_name": "Stretch", - "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", - "notifications": {} - }, - { +{ + "devices": { "0000aaaa0000aaaa0000aaaa0000aa00": { "dev_class": "gateway", "firmware": "3.1.11", @@ -12,8 +7,27 @@ "mac_address": "01:23:45:67:89:AB", "model": "Gateway", "name": "Stretch", - "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise" + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "059e4d03c7a34d278add5c7a4a781d19": { + "dev_class": "washingmachine", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Wasmachine (52AC1)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" }, "5871317346d045bc9f6b987ef25ee638": { "dev_class": "water_heater_vessel", @@ -22,35 +36,25 @@ "location": "0000aaaa0000aaaa0000aaaa0000aa00", "model": "Circle type F", "name": "Boiler (1EB31)", - "zigbee_mac_address": "ABCD012345670A07", - "vendor": "Plugwise", "sensors": { "electricity_consumed": 1.19, "electricity_consumed_interval": 0.0, "electricity_produced": 0.0 }, "switches": { - "relay": true, - "lock": false - } - }, - "e1c884e7dede431dadee09506ec4f859": { - "dev_class": "refrigerator", - "firmware": "2011-06-27T10:47:37+02:00", - "hardware": "6539-0700-7330", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle+ type F", - "name": "Koelkast (92C4A)", - "zigbee_mac_address": "0123456789AB", - "vendor": "Plugwise", - "sensors": { - "electricity_consumed": 50.5, - "electricity_consumed_interval": 0.08, - "electricity_produced": 0.0 + "lock": false, + "relay": true }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "71e1944f2a944b26ad73323e399efef0": { + "dev_class": "switching", + "members": ["5ca521ac179d468e91d772eeeb8a2117"], + "model": "Switchgroup", + "name": "Test", "switches": { - "relay": true, - "lock": false + "relay": true } }, "aac7b735042c4832ac9ff33aae4f453b": { @@ -60,17 +64,17 @@ "location": "0000aaaa0000aaaa0000aaaa0000aa00", "model": "Circle type F", "name": "Vaatwasser (2a1ab)", - "zigbee_mac_address": "ABCD012345670A02", - "vendor": "Plugwise", "sensors": { "electricity_consumed": 0.0, "electricity_consumed_interval": 0.71, "electricity_produced": 0.0 }, "switches": { - "relay": true, - "lock": false - } + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" }, "cfe95cf3de1948c0b8955125bf754614": { "dev_class": "dryer", @@ -79,50 +83,32 @@ "location": "0000aaaa0000aaaa0000aaaa0000aa00", "model": "Circle type F", "name": "Droger (52559)", - "zigbee_mac_address": "ABCD012345670A04", - "vendor": "Plugwise", "sensors": { "electricity_consumed": 0.0, "electricity_consumed_interval": 0.0, "electricity_produced": 0.0 }, "switches": { - "relay": true, - "lock": false - } - }, - "059e4d03c7a34d278add5c7a4a781d19": { - "dev_class": "washingmachine", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Wasmachine (52AC1)", - "zigbee_mac_address": "ABCD012345670A01", - "vendor": "Plugwise", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 + "lock": false, + "relay": true }, - "switches": { - "relay": true, - "lock": false - } + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" }, - "71e1944f2a944b26ad73323e399efef0": { + "d03738edfcc947f7b8f4573571d90d2d": { "dev_class": "switching", + "members": [ + "059e4d03c7a34d278add5c7a4a781d19", + "cfe95cf3de1948c0b8955125bf754614" + ], "model": "Switchgroup", - "name": "Test", - "members": ["5ca521ac179d468e91d772eeeb8a2117"], + "name": "Schakel", "switches": { "relay": true } }, "d950b314e9d8499f968e6db8d82ef78c": { "dev_class": "report", - "model": "Switchgroup", - "name": "Stroomvreters", "members": [ "059e4d03c7a34d278add5c7a4a781d19", "5871317346d045bc9f6b987ef25ee638", @@ -130,21 +116,35 @@ "cfe95cf3de1948c0b8955125bf754614", "e1c884e7dede431dadee09506ec4f859" ], + "model": "Switchgroup", + "name": "Stroomvreters", "switches": { "relay": true } }, - "d03738edfcc947f7b8f4573571d90d2d": { - "dev_class": "switching", - "model": "Switchgroup", - "name": "Schakel", - "members": [ - "059e4d03c7a34d278add5c7a4a781d19", - "cfe95cf3de1948c0b8955125bf754614" - ], + "e1c884e7dede431dadee09506ec4f859": { + "dev_class": "refrigerator", + "firmware": "2011-06-27T10:47:37+02:00", + "hardware": "6539-0700-7330", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle+ type F", + "name": "Koelkast (92C4A)", + "sensors": { + "electricity_consumed": 50.5, + "electricity_consumed_interval": 0.08, + "electricity_produced": 0.0 + }, "switches": { + "lock": false, "relay": true - } + }, + "vendor": "Plugwise", + "zigbee_mac_address": "0123456789AB" } + }, + "gateway": { + "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", + "notifications": {}, + "smile_name": "Stretch" } -] +} diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index f4f2a3f3c5f..aec20bc4a0b 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import async_update_entity from tests.common import MockConfigEntry @@ -30,6 +29,10 @@ async def test_anna_climate_binary_sensor_entities( assert state assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.opentherm_compressor_state") + assert state + assert state.state == STATE_ON + async def test_anna_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry @@ -42,7 +45,9 @@ async def test_anna_climate_binary_sensor_change( assert state assert state.state == STATE_ON - await async_update_entity(hass, "binary_sensor.opentherm_dhw_state") + await hass.helpers.entity_component.async_update_entity( + "binary_sensor.opentherm_dhw_state" + ) state = hass.states.get("binary_sensor.opentherm_dhw_state") assert state diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 5636523a919..c73bd5b6190 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -135,6 +135,22 @@ async def test_adam_climate_entity_climate_changes( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} ) + await hass.services.async_call( + "climate", + "set_temperature", + { + "entity_id": "climate.zone_lisa_wk", + "hvac_mode": "heat", + "temperature": 25, + }, + blocking=True, + ) + + assert mock_smile_adam.set_temperature.call_count == 2 + mock_smile_adam.set_temperature.assert_called_with( + "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} + ) + with pytest.raises(ValueError): await hass.services.async_call( "climate", @@ -162,7 +178,7 @@ async def test_adam_climate_entity_climate_changes( blocking=True, ) - assert mock_smile_adam.set_temperature.call_count == 2 + assert mock_smile_adam.set_temperature.call_count == 3 mock_smile_adam.set_temperature.assert_called_with( "82fa13f017d240daa0d0ea1775420f24", {"setpoint": 25.0} ) diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 6f73619ea77..5dde8a0e09e 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -15,6 +15,7 @@ async def test_diagnostics( init_integration: MockConfigEntry, ) -> None: """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( hass, hass_client, init_integration ) == { @@ -55,7 +56,7 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "selected_schedule": "None", + "select_schedule": "None", "last_used": "Badkamer Schema", "mode": "heat", "sensors": {"temperature": 16.5, "setpoint": 13.0, "battery": 67}, @@ -120,7 +121,7 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "selected_schedule": "GF7 Woonkamer", + "select_schedule": "GF7 Woonkamer", "last_used": "GF7 Woonkamer", "mode": "auto", "sensors": {"temperature": 20.9, "setpoint": 21.5, "battery": 34}, @@ -135,7 +136,7 @@ async def test_diagnostics( "name": "Adam", "zigbee_mac_address": "ABCD012345670101", "vendor": "Plugwise", - "regulation_mode": "heating", + "select_regulation_mode": "heating", "binary_sensors": {"plugwise_notification": True}, "sensors": {"outdoor_temperature": 7.81}, }, @@ -296,7 +297,7 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "selected_schedule": "CV Jessie", + "select_schedule": "CV Jessie", "last_used": "CV Jessie", "mode": "auto", "sensors": {"temperature": 17.2, "setpoint": 15.0, "battery": 37}, @@ -344,7 +345,7 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "selected_schedule": "Badkamer Schema", + "select_schedule": "Badkamer Schema", "last_used": "Badkamer Schema", "mode": "auto", "sensors": {"temperature": 18.9, "setpoint": 14.0, "battery": 92}, @@ -391,7 +392,7 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "selected_schedule": "None", + "select_schedule": "None", "last_used": "Badkamer Schema", "mode": "heat", "sensors": { diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 1ed1e509cef..1b5297b71d2 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -99,7 +99,7 @@ async def test_migrate_unique_id_temperature( mock_config_entry.add_to_hass(hass) entity_registry = er.async_get(hass) - entity: er.RegistryEntry = entity_registry.async_get_or_create( + entity: entity_registry.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, ) @@ -140,7 +140,7 @@ async def test_migrate_unique_id_relay( mock_config_entry.add_to_hass(hass) entity_registry = er.async_get(hass) - entity: er.RegistryEntry = entity_registry.async_get_or_create( + entity: entity_registry.RegistryEntry = entity_registry.async_get_or_create( **entitydata, config_entry=mock_config_entry, ) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index da31b8038c8..9ca64e104d3 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -40,3 +40,32 @@ async def test_anna_max_boiler_temp_change( mock_smile_anna.set_number_setpoint.assert_called_with( "maximum_boiler_temperature", 65.0 ) + + +async def test_adam_number_entities( + hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test creation of a number.""" + state = hass.states.get("number.opentherm_domestic_hot_water_setpoint") + assert state + assert float(state.state) == 60.0 + + +async def test_adam_dhw_setpoint_change( + hass: HomeAssistant, mock_smile_adam_2: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of number entities.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.opentherm_domestic_hot_water_setpoint", + ATTR_VALUE: 55, + }, + blocking=True, + ) + + assert mock_smile_adam_2.set_number_setpoint.call_count == 1 + mock_smile_adam_2.set_number_setpoint.assert_called_with( + "max_dhw_temperature", 55.0 + ) diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 0c7483c19bd..46f31e1458f 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +from homeassistant.components.plugwise.const import DOMAIN +from homeassistant.components.plugwise.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_registry import async_get @@ -36,6 +38,58 @@ async def test_adam_climate_sensor_entities( assert int(state.state) == 34 +async def test_adam_climate_sensor_entity_2( + hass: HomeAssistant, mock_smile_adam_4: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test creation of climate related sensor entities.""" + state = hass.states.get("sensor.woonkamer_humidity") + assert state + assert float(state.state) == 56.2 + + +async def test_unique_id_migration_humidity( + hass: HomeAssistant, + mock_smile_adam_4: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unique ID migration of -relative_humidity to -humidity.""" + mock_config_entry.add_to_hass(hass) + + entity_registry = async_get(hass) + # Entry to migrate + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "f61f1a2535f54f52ad006a3d18e459ca-relative_humidity", + config_entry=mock_config_entry, + suggested_object_id="woonkamer_humidity", + disabled_by=None, + ) + # Entry not needing migration + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "f61f1a2535f54f52ad006a3d18e459ca-battery", + config_entry=mock_config_entry, + suggested_object_id="woonkamer_battery", + disabled_by=None, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.woonkamer_humidity") is not None + assert hass.states.get("sensor.woonkamer_battery") is not None + + entity_entry = entity_registry.async_get("sensor.woonkamer_humidity") + assert entity_entry + assert entity_entry.unique_id == "f61f1a2535f54f52ad006a3d18e459ca-humidity" + + entity_entry = entity_registry.async_get("sensor.woonkamer_battery") + assert entity_entry + assert entity_entry.unique_id == "f61f1a2535f54f52ad006a3d18e459ca-battery" + + async def test_anna_as_smt_climate_sensor_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -48,10 +102,6 @@ async def test_anna_as_smt_climate_sensor_entities( assert state assert float(state.state) == 29.1 - state = hass.states.get("sensor.opentherm_dhw_setpoint") - assert state - assert float(state.state) == 60.0 - state = hass.states.get("sensor.opentherm_dhw_temperature") assert state assert float(state.state) == 46.3 diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index 64519aba0a8..2d47a420fe8 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -173,7 +173,7 @@ async def test_unique_id_migration_plug_relay( DOMAIN, "675416a629f343c495449970e2ca37b5-relay", config_entry=mock_config_entry, - suggested_object_id="router", + suggested_object_id="ziggo_modem", disabled_by=None, ) @@ -181,12 +181,12 @@ async def test_unique_id_migration_plug_relay( await hass.async_block_till_done() assert hass.states.get("switch.playstation_smart_plug") is not None - assert hass.states.get("switch.router") is not None + assert hass.states.get("switch.ziggo_modem") is not None entity_entry = registry.async_get("switch.playstation_smart_plug") assert entity_entry assert entity_entry.unique_id == "21f2b542c49845e6bb416884c55778d6-relay" - entity_entry = registry.async_get("switch.router") + entity_entry = registry.async_get("switch.ziggo_modem") assert entity_entry assert entity_entry.unique_id == "675416a629f343c495449970e2ca37b5-relay" diff --git a/tests/components/powerwall/test_binary_sensor.py b/tests/components/powerwall/test_binary_sensor.py index acea33186a8..b0a62f42368 100644 --- a/tests/components/powerwall/test_binary_sensor.py +++ b/tests/components/powerwall/test_binary_sensor.py @@ -26,47 +26,50 @@ async def test_sensors(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.grid_services_active") + state = hass.states.get("binary_sensor.mysite_grid_services_active") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Grid Services Active", + "friendly_name": "MySite Grid services active", "device_class": "power", } # 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()) - state = hass.states.get("binary_sensor.grid_status") - assert state.state == STATE_ON - expected_attributes = {"friendly_name": "Grid Status", "device_class": "power"} - # 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()) - - state = hass.states.get("binary_sensor.powerwall_status") + state = hass.states.get("binary_sensor.mysite_grid_status") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Powerwall Status", + "friendly_name": "MySite Grid status", "device_class": "power", } # 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()) - state = hass.states.get("binary_sensor.powerwall_connected_to_tesla") + state = hass.states.get("binary_sensor.mysite_status") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Powerwall Connected to Tesla", + "friendly_name": "MySite Status", + "device_class": "power", + } + # 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()) + + state = hass.states.get("binary_sensor.mysite_connected_to_tesla") + assert state.state == STATE_ON + expected_attributes = { + "friendly_name": "MySite Connected to Tesla", "device_class": "connectivity", } # 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()) - state = hass.states.get("binary_sensor.powerwall_charging") + state = hass.states.get("binary_sensor.mysite_charging") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Powerwall Charging", + "friendly_name": "MySite Charging", "device_class": "battery_charging", } # Only test for a subset of attributes in case diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index a0d4d7f9e96..e7772571c86 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -47,58 +47,58 @@ async def test_sensors( assert reg_device.manufacturer == "Tesla" assert reg_device.name == "MySite" - state = hass.states.get("sensor.powerwall_load_now") + state = hass.states.get("sensor.mysite_load_power") assert state.state == "1.971" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "power" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "kW" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load power" - state = hass.states.get("sensor.powerwall_load_frequency_now") + state = hass.states.get("sensor.mysite_load_frequency") assert state.state == "60" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "frequency" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "Hz" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Frequency Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load frequency" - state = hass.states.get("sensor.powerwall_load_average_voltage_now") + state = hass.states.get("sensor.mysite_load_voltage") assert state.state == "120.7" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "voltage" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "V" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Average Voltage Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load voltage" - state = hass.states.get("sensor.powerwall_load_average_current_now") + state = hass.states.get("sensor.mysite_load_current") assert state.state == "0" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "current" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "A" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Average Current Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load current" - assert float(hass.states.get("sensor.powerwall_load_export").state) == 1056.8 - assert float(hass.states.get("sensor.powerwall_load_import").state) == 4693.0 + assert float(hass.states.get("sensor.mysite_load_export").state) == 1056.8 + assert float(hass.states.get("sensor.mysite_load_import").state) == 4693.0 - state = hass.states.get("sensor.powerwall_battery_now") + state = hass.states.get("sensor.mysite_battery_power") assert state.state == "-8.55" - assert float(hass.states.get("sensor.powerwall_battery_export").state) == 3620.0 - assert float(hass.states.get("sensor.powerwall_battery_import").state) == 4216.2 + assert float(hass.states.get("sensor.mysite_battery_export").state) == 3620.0 + assert float(hass.states.get("sensor.mysite_battery_import").state) == 4216.2 - state = hass.states.get("sensor.powerwall_solar_now") + state = hass.states.get("sensor.mysite_solar_power") assert state.state == "10.49" - assert float(hass.states.get("sensor.powerwall_solar_export").state) == 9864.2 - assert float(hass.states.get("sensor.powerwall_solar_import").state) == 28.2 + assert float(hass.states.get("sensor.mysite_solar_export").state) == 9864.2 + assert float(hass.states.get("sensor.mysite_solar_import").state) == 28.2 - state = hass.states.get("sensor.powerwall_charge") + state = hass.states.get("sensor.mysite_charge") assert state.state == "47" expected_attributes = { "unit_of_measurement": PERCENTAGE, - "friendly_name": "Powerwall Charge", + "friendly_name": "MySite Charge", "device_class": "battery", } # Only test for a subset of attributes in case @@ -106,11 +106,11 @@ async def test_sensors( for key, value in expected_attributes.items(): assert state.attributes[key] == value - state = hass.states.get("sensor.powerwall_backup_reserve") + state = hass.states.get("sensor.mysite_backup_reserve") assert state.state == "15" expected_attributes = { "unit_of_measurement": PERCENTAGE, - "friendly_name": "Powerwall Backup Reserve", + "friendly_name": "MySite Backup reserve", "device_class": "battery", } # Only test for a subset of attributes in case diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 8b0acb9c5b0..07a666946fb 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -107,6 +107,34 @@ async def generate_latest_metrics(client): return body +@pytest.mark.parametrize("namespace", [""]) +async def test_setup_enumeration(hass, hass_client, entity_registry, namespace): + """Test that setup enumerates existing states/entities.""" + + # The order of when things are created must be carefully controlled in + # this test, so we don't use fixtures. + + sensor_1 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_1", + unit_of_measurement=UnitOfTemperature.CELSIUS, + original_device_class=SensorDeviceClass.TEMPERATURE, + suggested_object_id="outside_temperature", + original_name="Outside Temperature", + ) + set_state_with_entry(hass, sensor_1, 12.3, {}) + assert await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}) + + client = await hass_client() + body = await generate_latest_metrics(client) + assert ( + 'homeassistant_sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 12.3' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_view_empty_namespace(client, sensor_entities) -> None: """Test prometheus metrics view.""" @@ -232,6 +260,12 @@ async def test_sensor_device_class(client, sensor_entities) -> None: 'friendly_name="Radio Energy"} 14.0' in body ) + assert ( + 'sensor_timestamp_seconds{domain="sensor",' + 'entity="sensor.timestamp",' + 'friendly_name="Timestamp"} 1.691445808136036e+09' in body + ) + @pytest.mark.parametrize("namespace", [""]) async def test_input_number(client, input_number_entities) -> None: @@ -509,6 +543,23 @@ async def test_cover(client, cover_entities) -> None: assert tilt_position_metric in body +@pytest.mark.parametrize("namespace", [""]) +async def test_device_tracker(client, device_tracker_entities) -> None: + """Test prometheus metrics for device_tracker.""" + body = await generate_latest_metrics(client) + + assert ( + 'device_tracker_state{domain="device_tracker",' + 'entity="device_tracker.phone",' + 'friendly_name="Phone"} 1.0' in body + ) + assert ( + 'device_tracker_state{domain="device_tracker",' + 'entity="device_tracker.watch",' + 'friendly_name="Watch"} 0.0' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_counter(client, counter_entities) -> None: """Test prometheus metrics for counter.""" @@ -1032,6 +1083,16 @@ async def sensor_fixture( set_state_with_entry(hass, sensor_11, 50) data["sensor_11"] = sensor_11 + sensor_12 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_12", + original_device_class=SensorDeviceClass.TIMESTAMP, + suggested_object_id="Timestamp", + original_name="Timestamp", + ) + set_state_with_entry(hass, sensor_12, "2023-08-07T15:03:28.136036-0700") + data["sensor_12"] = sensor_12 await hass.async_block_till_done() return data @@ -1581,6 +1642,7 @@ async def test_full_config(hass: HomeAssistant, mock_client) -> None: "namespace": "ns", "default_metric": "m", "override_metric": "m", + "requires_auth": False, "component_config": {"fake.test": {"override_metric": "km"}}, "component_config_glob": {"fake.time_*": {"override_metric": "h"}}, "component_config_domain": {"climate": {"override_metric": "°C"}}, diff --git a/tests/components/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py index 51086e74b00..d5244de1b43 100644 --- a/tests/components/prosegur/test_alarm_control_panel.py +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -59,7 +59,7 @@ async def test_entity_registry( state = hass.states.get(PROSEGUR_ALARM_ENTITY) - assert state.attributes.get(ATTR_FRIENDLY_NAME) == f"contract {CONTRACT}" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == f"Contract {CONTRACT}" assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py index ba2e478f5cd..58017085aed 100644 --- a/tests/components/prosegur/test_camera.py +++ b/tests/components/prosegur/test_camera.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError async def test_camera(hass: HomeAssistant, init_integration) -> None: """Test prosegur get_image.""" - image = await camera.async_get_image(hass, "camera.test_cam") + image = await camera.async_get_image(hass, "camera.contract_1234abcd_test_cam") assert image == Image(content_type="image/jpeg", content=b"ABC") @@ -36,7 +36,7 @@ async def test_camera_fail( with caplog.at_level( logging.ERROR, logger="homeassistant.components.prosegur" ), pytest.raises(HomeAssistantError) as exc: - await camera.async_get_image(hass, "camera.test_cam") + await camera.async_get_image(hass, "camera.contract_1234abcd_test_cam") assert "Unable to get image" in str(exc.value) @@ -51,7 +51,7 @@ async def test_request_image( await hass.services.async_call( DOMAIN, "request_image", - {ATTR_ENTITY_ID: "camera.test_cam"}, + {ATTR_ENTITY_ID: "camera.contract_1234abcd_test_cam"}, ) await hass.async_block_till_done() @@ -72,7 +72,7 @@ async def test_request_image_fail( await hass.services.async_call( DOMAIN, "request_image", - {ATTR_ENTITY_ID: "camera.test_cam"}, + {ATTR_ENTITY_ID: "camera.contract_1234abcd_test_cam"}, ) await hass.async_block_till_done() diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index 6a0944bdf36..0f2a966b4e4 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -1,6 +1,6 @@ """Test Prusalink sensors.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import PropertyMock, patch import pytest @@ -125,7 +125,7 @@ async def test_sensors_active_job( """Test sensors while active job.""" with patch( "homeassistant.components.prusalink.sensor.utcnow", - return_value=datetime(2022, 8, 27, 14, 0, 0, tzinfo=timezone.utc), + return_value=datetime(2022, 8, 27, 14, 0, 0, tzinfo=UTC), ): assert await async_setup_component(hass, "prusalink", {}) diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index 702fd313e6d..242470fa8e7 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -56,8 +56,8 @@ MOCK_CONFIG_ADDITIONAL = { CONF_CODE: MOCK_CODE, } MOCK_DATA = {CONF_TOKEN: MOCK_CREDS, "devices": [MOCK_DEVICE]} -MOCK_UDP_PORT = int(987) -MOCK_TCP_PORT = int(997) +MOCK_UDP_PORT = 987 +MOCK_TCP_PORT = 997 MOCK_AUTO = {"Config Mode": "Auto Discover"} MOCK_MANUAL = {"Config Mode": "Manual Entry", CONF_IP_ADDRESS: MOCK_HOST} diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index acb84186c0b..74b13d2f909 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -325,6 +325,7 @@ async def test_device_info_is_assummed( ) -> None: """Test that device info is assumed if device is unavailable.""" # Create a device registry entry with device info. + MOCK_CONFIG.add_to_hass(hass) device_registry.async_get_or_create( config_entry_id=MOCK_ENTRY_ID, name=MOCK_HOST_NAME, diff --git a/tests/components/pushbullet/test_config_flow.py b/tests/components/pushbullet/test_config_flow.py index f250c22c443..d7baef682b8 100644 --- a/tests/components/pushbullet/test_config_flow.py +++ b/tests/components/pushbullet/test_config_flow.py @@ -4,10 +4,11 @@ from unittest.mock import patch from pushbullet import InvalidKeyError, PushbulletError import pytest -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components.pushbullet.const import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from . import MOCK_CONFIG @@ -33,7 +34,7 @@ async def test_flow_user(hass: HomeAssistant, requests_mock_fixture) -> None: result["flow_id"], user_input=MOCK_CONFIG, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "pushbullet" assert result["data"] == MOCK_CONFIG @@ -58,7 +59,7 @@ async def test_flow_user_already_configured( result["flow_id"], user_input=MOCK_CONFIG, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -83,7 +84,7 @@ async def test_flow_name_already_configured(hass: HomeAssistant) -> None: result["flow_id"], user_input=new_config, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -99,7 +100,7 @@ async def test_flow_invalid_key(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_USER}, data=MOCK_CONFIG, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} @@ -116,6 +117,6 @@ async def test_flow_conn_error(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_USER}, data=MOCK_CONFIG, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 8623830f0dd..6560c81ebbb 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for the pvpc_hourly_pricing config_flow.""" from datetime import datetime, timedelta -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries, data_entry_flow from homeassistant.components.pvpc_hourly_pricing import ( @@ -25,7 +25,9 @@ _MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=dt_util.UTC) async def test_config_flow( - hass: HomeAssistant, pvpc_aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + pvpc_aioclient_mock: AiohttpClientMocker, ) -> None: """Test config flow for pvpc_hourly_pricing. @@ -35,6 +37,7 @@ async def test_config_flow( - Check removal and add again to check state restoration - Configure options to change power and tariff to "2.0TD" """ + freezer.move_to(_MOCK_TIME_VALID_RESPONSES) hass.config.set_time_zone("Europe/Madrid") tst_config = { CONF_NAME: "test", @@ -43,84 +46,82 @@ async def test_config_flow( ATTR_POWER_P3: 5.75, } - with freeze_time(_MOCK_TIME_VALID_RESPONSES) as mock_time: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], tst_config - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 1 + await hass.async_block_till_done() + state = hass.states.get("sensor.esios_pvpc") + check_valid_state(state, tariff=TARIFFS[1]) + assert pvpc_aioclient_mock.call_count == 1 - # Check abort when configuring another with same tariff - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], tst_config - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert pvpc_aioclient_mock.call_count == 1 + # Check abort when configuring another with same tariff + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert pvpc_aioclient_mock.call_count == 1 - # Check removal - registry = er.async_get(hass) - registry_entity = registry.async_get("sensor.test") - assert await hass.config_entries.async_remove(registry_entity.config_entry_id) + # Check removal + registry = er.async_get(hass) + registry_entity = registry.async_get("sensor.esios_pvpc") + assert await hass.config_entries.async_remove(registry_entity.config_entry_id) - # and add it again with UI - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + # and add it again with UI + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], tst_config - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure( + result["flow_id"], tst_config + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 2 - assert state.attributes["period"] == "P3" - assert state.attributes["next_period"] == "P2" - assert state.attributes["available_power"] == 5750 + await hass.async_block_till_done() + state = hass.states.get("sensor.esios_pvpc") + check_valid_state(state, tariff=TARIFFS[1]) + assert pvpc_aioclient_mock.call_count == 2 + assert state.attributes["period"] == "P3" + assert state.attributes["next_period"] == "P2" + assert state.attributes["available_power"] == 5750 - # check options flow - current_entries = hass.config_entries.async_entries(DOMAIN) - assert len(current_entries) == 1 - config_entry = current_entries[0] + # check options flow + current_entries = hass.config_entries.async_entries(DOMAIN) + assert len(current_entries) == 1 + config_entry = current_entries[0] - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6}, - ) - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 3 - assert state.attributes["period"] == "P3" - assert state.attributes["next_period"] == "P2" - assert state.attributes["available_power"] == 4600 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6}, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.esios_pvpc") + check_valid_state(state, tariff=TARIFFS[1]) + assert pvpc_aioclient_mock.call_count == 3 + assert state.attributes["period"] == "P3" + assert state.attributes["next_period"] == "P2" + assert state.attributes["available_power"] == 4600 - # check update failed - ts_future = _MOCK_TIME_VALID_RESPONSES + timedelta(days=1) - mock_time.move_to(ts_future) - async_fire_time_changed(hass, ts_future) - await hass.async_block_till_done() - state = hass.states.get("sensor.test") - check_valid_state(state, tariff=TARIFFS[0], value="unavailable") - assert "period" not in state.attributes - assert pvpc_aioclient_mock.call_count == 4 + # check update failed + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("sensor.esios_pvpc") + check_valid_state(state, tariff=TARIFFS[0], value="unavailable") + assert "period" not in state.attributes + assert pvpc_aioclient_mock.call_count == 4 diff --git a/tests/components/qingping/test_binary_sensor.py b/tests/components/qingping/test_binary_sensor.py index 5733f4f145b..9b83cd8c590 100644 --- a/tests/components/qingping/test_binary_sensor.py +++ b/tests/components/qingping/test_binary_sensor.py @@ -1,12 +1,23 @@ """Test the Qingping binary sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.qingping.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import LIGHT_AND_SIGNAL_SERVICE_INFO +from . import LIGHT_AND_SIGNAL_SERVICE_INFO, NO_DATA_SERVICE_INFO -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) async def test_binary_sensors(hass: HomeAssistant) -> None: @@ -31,3 +42,58 @@ async def test_binary_sensors(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: + """Test setting up creates the binary sensors and restoring state.""" + start_monotonic = time.monotonic() + + 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("binary_sensor")) == 0 + inject_bluetooth_service_info(hass, LIGHT_AND_SIGNAL_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("binary_sensor")) == 1 + + motion_sensor = hass.states.get("binary_sensor.motion_light_eeff_motion") + assert motion_sensor.state == STATE_OFF + assert motion_sensor.attributes[ATTR_FRIENDLY_NAME] == "Motion & Light EEFF Motion" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Device is no longer available because its not in range + + motion_sensor = hass.states.get("binary_sensor.motion_light_eeff_motion") + assert motion_sensor.state == STATE_UNAVAILABLE + + # Device is back in range + + inject_bluetooth_service_info(hass, NO_DATA_SERVICE_INFO) + + motion_sensor = hass.states.get("binary_sensor.motion_light_eeff_motion") + assert motion_sensor.state == STATE_OFF diff --git a/tests/components/qingping/test_sensor.py b/tests/components/qingping/test_sensor.py index d80522f47c9..2fedbba9e5c 100644 --- a/tests/components/qingping/test_sensor.py +++ b/tests/components/qingping/test_sensor.py @@ -1,13 +1,28 @@ """Test the Qingping sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.qingping.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import LIGHT_AND_SIGNAL_SERVICE_INFO +from . import LIGHT_AND_SIGNAL_SERVICE_INFO, NO_DATA_SERVICE_INFO -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) async def test_sensors(hass: HomeAssistant) -> None: @@ -35,3 +50,60 @@ async def test_sensors(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: + """Test setting up creates the binary sensors and restoring state.""" + start_monotonic = time.monotonic() + 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("sensor")) == 0 + inject_bluetooth_service_info(hass, LIGHT_AND_SIGNAL_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 1 + + lux_sensor = hass.states.get("sensor.motion_light_eeff_illuminance") + lux_sensor_attrs = lux_sensor.attributes + assert lux_sensor.state == "13" + assert lux_sensor_attrs[ATTR_FRIENDLY_NAME] == "Motion & Light EEFF Illuminance" + assert lux_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "lx" + assert lux_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Device is no longer available because its not in range + + lux_sensor = hass.states.get("sensor.motion_light_eeff_illuminance") + assert lux_sensor.state == STATE_UNAVAILABLE + + # Device is back in range + + inject_bluetooth_service_info(hass, NO_DATA_SERVICE_INFO) + + lux_sensor = hass.states.get("sensor.motion_light_eeff_illuminance") + assert lux_sensor.state == "13" diff --git a/tests/components/qnap_qsw/test_binary_sensor.py b/tests/components/qnap_qsw/test_binary_sensor.py index 47eb6a00ba7..3540eb6ba4a 100644 --- a/tests/components/qnap_qsw/test_binary_sensor.py +++ b/tests/components/qnap_qsw/test_binary_sensor.py @@ -17,7 +17,7 @@ async def test_qnap_qsw_create_binary_sensors( await async_init_integration(hass) - state = hass.states.get("binary_sensor.qsw_m408_4c_anomaly") + state = hass.states.get("binary_sensor.qsw_m408_4c_problem") assert state.state == STATE_OFF assert state.attributes.get(ATTR_MESSAGE) is None diff --git a/tests/components/qnap_qsw/test_button.py b/tests/components/qnap_qsw/test_button.py index 43e0ee4ba38..27b5fcb075d 100644 --- a/tests/components/qnap_qsw/test_button.py +++ b/tests/components/qnap_qsw/test_button.py @@ -14,7 +14,7 @@ async def test_qnap_buttons(hass: HomeAssistant) -> None: await async_init_integration(hass) - state = hass.states.get("button.qsw_m408_4c_reboot") + state = hass.states.get("button.qsw_m408_4c_restart") assert state assert state.state == STATE_UNKNOWN @@ -28,7 +28,7 @@ async def test_qnap_buttons(hass: HomeAssistant) -> None: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.qsw_m408_4c_reboot"}, + {ATTR_ENTITY_ID: "button.qsw_m408_4c_restart"}, blocking=True, ) await hass.async_block_till_done() diff --git a/tests/components/qnap_qsw/test_coordinator.py b/tests/components/qnap_qsw/test_coordinator.py index 61d1fa04200..8a5f07e8173 100644 --- a/tests/components/qnap_qsw/test_coordinator.py +++ b/tests/components/qnap_qsw/test_coordinator.py @@ -3,6 +3,7 @@ from unittest.mock import patch from aioqsw.exceptions import APIError, QswError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.components.qnap_qsw.coordinator import ( @@ -11,7 +12,6 @@ from homeassistant.components.qnap_qsw.coordinator import ( ) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow from .util import ( CONFIG, @@ -31,7 +31,9 @@ from .util import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: +async def test_coordinator_client_connector_error( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test ClientConnectorError on coordinator update.""" entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) @@ -99,7 +101,8 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: mock_users_login.reset_mock() mock_system_sensor.side_effect = QswError - async_fire_time_changed(hass, utcnow() + DATA_SCAN_INTERVAL) + freezer.tick(DATA_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_system_sensor.assert_called_once() @@ -110,17 +113,19 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE mock_firmware_update_check.side_effect = APIError - async_fire_time_changed(hass, utcnow() + FW_SCAN_INTERVAL) + freezer.tick(FW_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_firmware_update_check.assert_called_once() mock_firmware_update_check.reset_mock() mock_firmware_update_check.side_effect = QswError - async_fire_time_changed(hass, utcnow() + FW_SCAN_INTERVAL) + freezer.tick(FW_SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_firmware_update_check.assert_called_once() - update = hass.states.get("update.qsw_m408_4c_firmware_update") + update = hass.states.get("update.qsw_m408_4c_firmware") assert update.state == STATE_UNAVAILABLE diff --git a/tests/components/qnap_qsw/test_update.py b/tests/components/qnap_qsw/test_update.py index 26b7157f64d..f6eb9705912 100644 --- a/tests/components/qnap_qsw/test_update.py +++ b/tests/components/qnap_qsw/test_update.py @@ -34,7 +34,7 @@ async def test_qnap_qsw_update(hass: HomeAssistant) -> None: await async_init_integration(hass) - update = hass.states.get("update.qsw_m408_4c_firmware_update") + update = hass.states.get("update.qsw_m408_4c_firmware") assert update is not None assert update.state == STATE_ON assert ( @@ -62,7 +62,7 @@ async def test_qnap_qsw_update(hass: HomeAssistant) -> None: SERVICE_INSTALL, { ATTR_BACKUP: False, - ATTR_ENTITY_ID: "update.qsw_m408_4c_firmware_update", + ATTR_ENTITY_ID: "update.qsw_m408_4c_firmware", }, blocking=True, ) @@ -71,7 +71,7 @@ async def test_qnap_qsw_update(hass: HomeAssistant) -> None: mock_firmware_update_live.assert_called_once() mock_users_verification.assert_called() - update = hass.states.get("update.qsw_m408_4c_firmware_update") + update = hass.states.get("update.qsw_m408_4c_firmware") assert update is not None assert update.state == STATE_OFF assert ( diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 0e328b50f94..5527e311114 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -3,11 +3,11 @@ from unittest.mock import patch from aiopyarr import exceptions -from homeassistant import data_entry_flow from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from . import ( API_KEY, @@ -33,7 +33,7 @@ async def test_show_user_form(hass: HomeAssistant) -> None: ) assert result["step_id"] == "user" - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM async def test_cannot_connect( @@ -48,7 +48,7 @@ async def test_cannot_connect( data=MOCK_USER_INPUT, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} @@ -62,7 +62,7 @@ async def test_invalid_auth( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=MOCK_USER_INPUT ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_auth"} @@ -79,7 +79,7 @@ async def test_wrong_app(hass: HomeAssistant) -> None: data={CONF_URL: URL, CONF_VERIFY_SSL: False}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "wrong_app" @@ -96,7 +96,7 @@ async def test_zero_conf_failure(hass: HomeAssistant) -> None: data={CONF_URL: URL, CONF_VERIFY_SSL: False}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "zeroconf_failed" @@ -113,7 +113,7 @@ async def test_unknown_error(hass: HomeAssistant) -> None: data=MOCK_USER_INPUT, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} @@ -130,7 +130,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: data={CONF_URL: URL, CONF_VERIFY_SSL: False}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == CONF_DATA @@ -150,14 +150,14 @@ async def test_full_reauth_flow_implementation( data=entry.data, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch_async_setup_entry() as mock_setup_entry: @@ -166,7 +166,7 @@ async def test_full_reauth_flow_implementation( ) await hass.async_block_till_done() - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert entry.data == CONF_DATA | {CONF_API_KEY: "test-api-key-reauth"} @@ -185,7 +185,7 @@ async def test_full_user_flow_implementation( context={CONF_SOURCE: SOURCE_USER}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch_async_setup_entry(): @@ -194,7 +194,7 @@ async def test_full_user_flow_implementation( user_input=MOCK_USER_INPUT, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == CONF_DATA assert result["data"][CONF_URL] == "http://192.168.1.189:7887/test" diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index 21ad5230581..9e4e4e546cb 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -72,11 +72,6 @@ CONFIG_ENTRY_DATA = { } -UNAVAILABLE_RESPONSE = AiohttpClientMockResponse( - "POST", URL, status=HTTPStatus.SERVICE_UNAVAILABLE -) - - @pytest.fixture def platforms() -> list[Platform]: """Fixture to specify platforms to test.""" @@ -150,6 +145,13 @@ def mock_response(data: str) -> AiohttpClientMockResponse: return AiohttpClientMockResponse("POST", URL, response=rainbird_response(data)) +def mock_response_error( + status: HTTPStatus = HTTPStatus.SERVICE_UNAVAILABLE, +) -> AiohttpClientMockResponse: + """Create a fake AiohttpClientMockResponse.""" + return AiohttpClientMockResponse("POST", URL, status=status) + + @pytest.fixture(name="stations_response") def mock_station_response() -> str: """Mock response to return available stations.""" diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index 31650a0828a..f11eba4fed7 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -108,7 +108,7 @@ async def test_controller_timeout( """Test an error talking to the controller.""" with patch( - "homeassistant.components.rainbird.config_flow.async_timeout.timeout", + "homeassistant.components.rainbird.config_flow.asyncio.timeout", side_effect=asyncio.TimeoutError, ): result = await complete_flow(hass) diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 1330f1cb4b2..f548d3aacda 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -2,13 +2,21 @@ from __future__ import annotations +from http import HTTPStatus + import pytest from homeassistant.components.rainbird import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .conftest import CONFIG_ENTRY_DATA, UNAVAILABLE_RESPONSE, ComponentSetup +from .conftest import ( + CONFIG_ENTRY_DATA, + MODEL_AND_VERSION_RESPONSE, + ComponentSetup, + mock_response, + mock_response_error, +) from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -44,16 +52,50 @@ async def test_init_success( @pytest.mark.parametrize( ("yaml_config", "config_entry_data", "responses", "config_entry_states"), [ - ({}, CONFIG_ENTRY_DATA, [UNAVAILABLE_RESPONSE], [ConfigEntryState.SETUP_RETRY]), + ( + {}, + CONFIG_ENTRY_DATA, + [mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE)], + [ConfigEntryState.SETUP_RETRY], + ), + ( + {}, + CONFIG_ENTRY_DATA, + [mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR)], + [ConfigEntryState.SETUP_RETRY], + ), + ( + {}, + CONFIG_ENTRY_DATA, + [ + mock_response(MODEL_AND_VERSION_RESPONSE), + mock_response_error(HTTPStatus.SERVICE_UNAVAILABLE), + ], + [ConfigEntryState.SETUP_RETRY], + ), + ( + {}, + CONFIG_ENTRY_DATA, + [ + mock_response(MODEL_AND_VERSION_RESPONSE), + mock_response_error(HTTPStatus.INTERNAL_SERVER_ERROR), + ], + [ConfigEntryState.SETUP_RETRY], + ), + ], + ids=[ + "unavailable", + "server-error", + "coordinator-unavailable", + "coordinator-server-error", ], - ids=["config_entry_failure"], ) async def test_communication_failure( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry_states: list[ConfigEntryState], ) -> None: - """Test unable to talk to server on startup, which permanently fails setup.""" + """Test unable to talk to device on startup, which fails setup.""" assert await setup_integration() diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 1335a1595d3..2c837a75c66 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -1,5 +1,6 @@ """Tests for rainbird number platform.""" +from http import HTTPStatus import pytest @@ -8,6 +9,7 @@ from homeassistant.components.rainbird import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from .conftest import ( @@ -17,6 +19,7 @@ from .conftest import ( SERIAL_NUMBER, ComponentSetup, mock_response, + mock_response_error, ) from tests.test_util.aiohttp import AiohttpClientMocker @@ -87,3 +90,40 @@ async def test_set_value( ) assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("status", "expected_msg"), + [ + (HTTPStatus.SERVICE_UNAVAILABLE, "Rain Bird device is busy"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "Rain Bird device failure"), + ], +) +async def test_set_value_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[str], + config_entry: ConfigEntry, + status: HTTPStatus, + expected_msg: str, +) -> None: + """Test an error while talking to the device.""" + + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + responses.append(mock_response_error(status=status)) + + with pytest.raises(HomeAssistantError, match=expected_msg): + await hass.services.async_call( + number.DOMAIN, + number.SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.rain_bird_controller_rain_delay", + number.ATTR_VALUE: 3, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 684287a5d1a..9127a0b0c61 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -1,11 +1,13 @@ """Tests for rainbird sensor platform.""" +from http import HTTPStatus import pytest from homeassistant.components.rainbird import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import ( ACK_ECHO, @@ -19,6 +21,7 @@ from .conftest import ( ZONE_OFF_RESPONSE, ComponentSetup, mock_response, + mock_response_error, ) from tests.components.switch import common as switch_common @@ -240,3 +243,36 @@ async def test_yaml_imported_config( assert hass.states.get("switch.back_yard") assert not hass.states.get("switch.rain_bird_sprinkler_2") assert hass.states.get("switch.rain_bird_sprinkler_3") + + +@pytest.mark.parametrize( + ("status", "expected_msg"), + [ + (HTTPStatus.SERVICE_UNAVAILABLE, "Rain Bird device is busy"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "Rain Bird device failure"), + ], +) +async def test_switch_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + status: HTTPStatus, + expected_msg: str, +) -> None: + """Test an error talking to the device.""" + + assert await setup_integration() + + aioclient_mock.mock_calls.clear() + responses.append(mock_response_error(status=status)) + + with pytest.raises(HomeAssistantError, match=expected_msg): + await switch_common.async_turn_on(hass, "switch.rain_bird_sprinkler_3") + await hass.async_block_till_done() + + responses.append(mock_response_error(status=status)) + + with pytest.raises(HomeAssistantError, match=expected_msg): + await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") + await hass.async_block_till_done() diff --git a/tests/components/rdw/snapshots/test_diagnostics.ambr b/tests/components/rdw/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6da03b67245 --- /dev/null +++ b/tests/components/rdw/snapshots/test_diagnostics.ambr @@ -0,0 +1,30 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'apk_expiration': '2022-01-04', + 'ascription_date': '2021-11-04', + 'ascription_possible': True, + 'brand': 'Skoda', + 'energy_label': 'A', + 'engine_capacity': 999, + 'exported': False, + 'first_admission': '2013-01-04', + 'interior': 'hatchback', + 'last_odometer_registration_year': 2021, + 'liability_insured': False, + 'license_plate': '11ZKZ3', + 'list_price': 10697, + 'mass_driveable': 940, + 'mass_empty': 840, + 'model': 'Citigo', + 'number_of_cylinders': 3, + 'number_of_doors': 0, + 'number_of_seats': 4, + 'number_of_wheelchair_seats': 0, + 'number_of_wheels': 4, + 'odometer_judgement': 'Logisch', + 'pending_recall': False, + 'taxi': None, + 'vehicle_type': 'Personenauto', + }) +# --- diff --git a/tests/components/rdw/test_diagnostics.py b/tests/components/rdw/test_diagnostics.py index 0e21779ff37..28b7714fcce 100644 --- a/tests/components/rdw/test_diagnostics.py +++ b/tests/components/rdw/test_diagnostics.py @@ -1,4 +1,5 @@ """Tests for the diagnostics data provided by the RDW integration.""" +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,34 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "apk_expiration": "2022-01-04", - "ascription_date": "2021-11-04", - "ascription_possible": True, - "brand": "Skoda", - "energy_label": "A", - "engine_capacity": 999, - "exported": False, - "interior": "hatchback", - "last_odometer_registration_year": 2021, - "liability_insured": False, - "license_plate": "11ZKZ3", - "list_price": 10697, - "first_admission": "2013-01-04", - "mass_empty": 840, - "mass_driveable": 940, - "model": "Citigo", - "number_of_cylinders": 3, - "number_of_doors": 0, - "number_of_seats": 4, - "number_of_wheelchair_seats": 0, - "number_of_wheels": 4, - "odometer_judgement": "Logisch", - "pending_recall": False, - "taxi": None, - "vehicle_type": "Personenauto", - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py index 1fd5d769c7c..1dc9fb1f560 100644 --- a/tests/components/recorder/auto_repairs/events/test_schema.py +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -1,6 +1,5 @@ """The test repairing events schema.""" -# pylint: disable=invalid-name from unittest.mock import ANY, patch import pytest diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py index 9b90489d7c0..f3d733c7c45 100644 --- a/tests/components/recorder/auto_repairs/states/test_schema.py +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -1,6 +1,5 @@ """The test repairing states schema.""" -# pylint: disable=invalid-name from unittest.mock import ANY, patch import pytest diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py index 98f46cadf03..0d0d9847c5d 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -1,7 +1,5 @@ """Test removing statistics duplicates.""" from collections.abc import Callable - -# pylint: disable=invalid-name import importlib from pathlib import Path import sys diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 10d1ed00b5b..032cd57ce49 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -1,6 +1,5 @@ """The test repairing statistics schema.""" -# pylint: disable=invalid-name from unittest.mock import ANY, patch import pytest diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py index ad2c33bfb88..83fb64dca6c 100644 --- a/tests/components/recorder/auto_repairs/test_schema.py +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -1,6 +1,5 @@ """The test validating and repairing schema.""" -# pylint: disable=invalid-name from unittest.mock import patch import pytest diff --git a/tests/components/recorder/db_schema_0.py b/tests/components/recorder/db_schema_0.py index 5f64fbda736..6365ff6a7e7 100644 --- a/tests/components/recorder/db_schema_0.py +++ b/tests/components/recorder/db_schema_0.py @@ -4,7 +4,6 @@ This file contains the original models definitions before schema tracking was implemented. It is used to test the schema migration logic. """ -from datetime import datetime import json import logging @@ -26,7 +25,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() _LOGGER = logging.getLogger(__name__) @@ -41,7 +39,7 @@ class Events(Base): # type: ignore event_data = Column(Text) origin = Column(String(32)) time_fired = Column(DateTime(timezone=True)) - created = Column(DateTime(timezone=True), default=datetime.utcnow) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) @staticmethod def from_event(event): @@ -78,9 +76,9 @@ class States(Base): # type: ignore state = Column(String(255)) attributes = Column(Text) event_id = Column(Integer, ForeignKey("events.event_id")) - last_changed = Column(DateTime(timezone=True), default=datetime.utcnow) - last_updated = Column(DateTime(timezone=True), default=datetime.utcnow) - created = Column(DateTime(timezone=True), default=datetime.utcnow) + last_changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + last_updated = Column(DateTime(timezone=True), default=dt_util.utcnow) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) __table_args__ = ( Index("states__state_changes", "last_changed", "last_updated", "entity_id"), @@ -132,10 +130,10 @@ class RecorderRuns(Base): # type: ignore __tablename__ = "recorder_runs" run_id = Column(Integer, primary_key=True) - start = Column(DateTime(timezone=True), default=datetime.utcnow) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) end = Column(DateTime(timezone=True)) closed_incorrect = Column(Boolean, default=False) - created = Column(DateTime(timezone=True), default=datetime.utcnow) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) def entity_ids(self, point_in_time=None): """Return the entity ids that existed in this run. diff --git a/tests/components/recorder/db_schema_16.py b/tests/components/recorder/db_schema_16.py index 23b0ec1f921..8c491b82c39 100644 --- a/tests/components/recorder/db_schema_16.py +++ b/tests/components/recorder/db_schema_16.py @@ -23,8 +23,7 @@ from sqlalchemy import ( distinct, ) from sqlalchemy.dialects import mysql -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.const import ( @@ -40,7 +39,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 16 diff --git a/tests/components/recorder/db_schema_18.py b/tests/components/recorder/db_schema_18.py index 3eeebc8e649..2ce0dfae5f5 100644 --- a/tests/components/recorder/db_schema_18.py +++ b/tests/components/recorder/db_schema_18.py @@ -23,8 +23,7 @@ from sqlalchemy import ( distinct, ) from sqlalchemy.dialects import mysql -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.const import ( @@ -40,7 +39,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 18 diff --git a/tests/components/recorder/db_schema_22.py b/tests/components/recorder/db_schema_22.py index 3bcef248e0f..329e5d262bc 100644 --- a/tests/components/recorder/db_schema_22.py +++ b/tests/components/recorder/db_schema_22.py @@ -45,7 +45,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 22 diff --git a/tests/components/recorder/db_schema_23.py b/tests/components/recorder/db_schema_23.py index 50839f41906..a89599520c0 100644 --- a/tests/components/recorder/db_schema_23.py +++ b/tests/components/recorder/db_schema_23.py @@ -43,7 +43,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 23 diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 9f73e304e9b..160ddc5761c 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -51,7 +51,6 @@ from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 23 diff --git a/tests/components/recorder/db_schema_25.py b/tests/components/recorder/db_schema_25.py index 291fdb1231d..24b5b764c65 100644 --- a/tests/components/recorder/db_schema_25.py +++ b/tests/components/recorder/db_schema_25.py @@ -39,7 +39,6 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 25 diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 7e88d6a5548..9df32f1b6c1 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -45,7 +45,6 @@ from homeassistant.core import Context, Event, EventOrigin, State, split_entity_ import homeassistant.util.dt as dt_util # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 28 diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 55bee20df56..c1a61159c98 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -55,7 +55,6 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 30 diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 660a2a54d4b..e092de28eca 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -55,7 +55,6 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} # SQLAlchemy Schema -# pylint: disable=invalid-name Base = declarative_base() SCHEMA_VERSION = 32 diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py index 9829996818f..9a03c024a83 100644 --- a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -1,5 +1,4 @@ """The tests for the recorder filter matching the EntityFilter component.""" -# pylint: disable=invalid-name import json from unittest.mock import patch diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index be77f2907d6..21016a65cc2 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -2,8 +2,6 @@ from __future__ import annotations from collections.abc import Callable - -# pylint: disable=invalid-name from copy import copy from datetime import datetime, timedelta import json diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index 30d8de654d7..0ed6061de98 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -2,8 +2,6 @@ from __future__ import annotations from collections.abc import Callable - -# pylint: disable=invalid-name from copy import copy from datetime import datetime, timedelta import json diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 51e4bfdc402..5b721cd4c87 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -2,8 +2,6 @@ from __future__ import annotations from collections.abc import Callable - -# pylint: disable=invalid-name from copy import copy from datetime import datetime, timedelta import json diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 4e9a0261ec2..e4e5e49eab5 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -594,7 +594,6 @@ def test_setup_without_migration(hass_recorder: Callable[..., HomeAssistant]) -> assert recorder.get_instance(hass).schema_version == SCHEMA_VERSION -# pylint: disable=invalid-name def test_saving_state_include_domains( hass_recorder: Callable[..., HomeAssistant] ) -> None: @@ -955,7 +954,6 @@ async def test_defaults_set(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "history", {}) assert recorder_config is not None - # pylint: disable=unsubscriptable-object assert recorder_config["auto_purge"] assert recorder_config["auto_repack"] assert recorder_config["purge_keep_days"] == 10 @@ -2224,7 +2222,7 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: return "mysql" @classmethod - def dbapi(cls): + def import_dbapi(cls): ... def engine_created(*args): diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 2ae32018213..cdf930fde26 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -1,5 +1,4 @@ """The tests for the recorder filter matching the EntityFilter component.""" -# pylint: disable=invalid-name import importlib import sys from unittest.mock import patch diff --git a/tests/components/recorder/test_pool.py b/tests/components/recorder/test_pool.py index f8442761200..3a6ff50af2b 100644 --- a/tests/components/recorder/test_pool.py +++ b/tests/components/recorder/test_pool.py @@ -26,14 +26,14 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: def _get_connection_twice(): session = get_session() - connections.append(session.connection().connection.connection) + connections.append(session.connection().connection.driver_connection) session.close() if shutdown: engine.pool.shutdown() session = get_session() - connections.append(session.connection().connection.connection) + connections.append(session.connection().connection.driver_connection) session.close() caplog.clear() diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 18c35e8eb81..3b315481f4e 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -1,6 +1,5 @@ """Test data purging.""" -# pylint: disable=invalid-name from datetime import datetime, timedelta import json import sqlite3 diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index de10d9f569b..ab89b82d713 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1,7 +1,5 @@ """The tests for sensor recorder platform.""" from collections.abc import Callable - -# pylint: disable=invalid-name from datetime import timedelta from unittest.mock import patch diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index c3d65e7290f..75a9fed4ad1 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -4,8 +4,6 @@ The v23 schema used for these tests has been slightly modified to add the EventData table to allow the recorder to startup successfully. """ from functools import partial - -# pylint: disable=invalid-name import importlib import json from pathlib import Path diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index ecfd188db8e..a7b15a7f12d 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -1,6 +1,6 @@ """Test util methods.""" from collections.abc import Callable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import os from pathlib import Path import sqlite3 @@ -948,7 +948,7 @@ def test_execute_stmt_lambda_element( assert rows == ["mock_row"] -@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=UTC)) async def test_resolve_period(hass: HomeAssistant) -> None: """Test statistic_during_period.""" diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index dae4fb39c59..98f401e45d8 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -1,5 +1,4 @@ """The tests for recorder platform migrating data from v30.""" -# pylint: disable=invalid-name import asyncio from datetime import timedelta import importlib diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 32d4fabb02b..a9dc23ef5b3 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1,5 +1,4 @@ """The tests for sensor recorder platform.""" -# pylint: disable=invalid-name import datetime from datetime import timedelta from statistics import fmean diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 4b2a7dfc72b..342ab803f33 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -198,6 +198,7 @@ MOCK_VEHICLES = { ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, }, { + ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: "145", @@ -433,6 +434,7 @@ MOCK_VEHICLES = { ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, }, { + ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: STATE_UNKNOWN, @@ -668,6 +670,7 @@ MOCK_VEHICLES = { ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, }, { + ATTR_DEVICE_CLASS: SensorDeviceClass.DURATION, ATTR_ENTITY_ID: "sensor.reg_number_charging_remaining_time", ATTR_ICON: "mdi:timer", ATTR_STATE: "145", diff --git a/tests/components/renault/fixtures/vehicle_missing_details.json b/tests/components/renault/fixtures/vehicle_missing_details.json new file mode 100644 index 00000000000..f6467e0c8f8 --- /dev/null +++ b/tests/components/renault/fixtures/vehicle_missing_details.json @@ -0,0 +1,25 @@ +{ + "accountId": "account-id-1", + "country": "FR", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777999", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "annualMileage": 16000, + "mileage": 26464, + "startDate": "2017-08-07", + "createdDate": "2019-05-23T21:38:16.409008Z", + "lastModifiedDate": "2020-11-17T08:41:40.497400Z", + "ownershipStartDate": "2017-08-01", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2019-06-17T09:49:06.880627Z", + "lastModifiedDate": "2019-06-17T09:49:06.880627Z" + } + } + ] +} diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index b4e2f105b3b..46b231ac7ef 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -404,7 +404,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -811,6 +811,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -1100,7 +1101,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -1505,6 +1506,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -1790,7 +1792,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -2223,6 +2225,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -2803,7 +2806,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -3210,6 +3213,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -3499,7 +3503,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -3904,6 +3908,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , @@ -4189,7 +4194,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', @@ -4622,6 +4627,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'REG-NUMBER Charging remaining time', 'icon': 'mdi:timer', 'state_class': , diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 415b07dc7e6..0f26bf6fbdb 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -68,7 +68,7 @@ async def test_setup_entry_exception( # ConfigEntryNotReady. with patch( "renault_api.renault_session.RenaultSession.login", - side_effect=aiohttp.ClientConnectionError, + side_effect=side_effect, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -95,3 +95,19 @@ async def test_setup_entry_kamereon_exception( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY assert not hass.data.get(DOMAIN) + + +@pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") +@pytest.mark.parametrize("vehicle_type", ["missing_details"], indirect=True) +async def test_setup_entry_missing_vehicle_details( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test ConfigEntryNotReady when vehicleDetails is missing.""" + # In this case we are testing the condition where renault_hub fails to retrieve + # vehicle details (see #99127). + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) diff --git a/tests/components/renson/test_config_flow.py b/tests/components/renson/test_config_flow.py index 6b9f54cd454..578c6125427 100644 --- a/tests/components/renson/test_config_flow.py +++ b/tests/components/renson/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components.renson.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.data_entry_flow import FlowResultType async def test_form(hass: HomeAssistant) -> None: @@ -12,7 +12,7 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] is None with patch( @@ -30,7 +30,7 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Renson" assert result2["data"] == { "host": "1.1.1.1", @@ -55,7 +55,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == RESULT_TYPE_FORM + assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} @@ -76,5 +76,5 @@ async def test_form_unknown(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == RESULT_TYPE_FORM + assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 1e6f9aa4902..25719c4cff7 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -58,18 +58,16 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None host_mock.is_admin = True host_mock.user_level = "admin" host_mock.sw_version_update_required = False + host_mock.hardware_version = "IPC_00000" + host_mock.sw_version = "v1.0.0.0.0.0000" + host_mock.manufacturer = "Reolink" + host_mock.model = "RLC-123" + host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 yield host_mock -@pytest.fixture -def reolink_ONVIF_wait() -> Generator[None, None, None]: - """Mock reolink connection.""" - with patch("homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock()): - yield - - @pytest.fixture def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index b6e48cab7b2..1a4bf999cce 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -1,18 +1,22 @@ """Test the Reolink config flow.""" +from datetime import timedelta import json -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.components.reolink import const +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL, const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.exceptions import ReolinkWebhookException +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.dt import utcnow from .conftest import ( TEST_HOST, @@ -27,14 +31,14 @@ from .conftest import ( TEST_USERNAME2, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures( - "mock_setup_entry", "reolink_connect", "reolink_ONVIF_wait" -) +pytestmark = pytest.mark.usefixtures("reolink_connect") -async def test_config_flow_manual_success(hass: HomeAssistant) -> None: +async def test_config_flow_manual_success( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Successful flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -68,7 +72,7 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -194,7 +198,7 @@ async def test_config_flow_errors( } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -232,7 +236,9 @@ async def test_options_flow(hass: HomeAssistant) -> None: } -async def test_change_connection_settings(hass: HomeAssistant) -> None: +async def test_change_connection_settings( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Test changing connection settings by issuing a second user config flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -275,7 +281,7 @@ async def test_change_connection_settings(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -335,7 +341,7 @@ async def test_reauth(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_dhcp_flow(hass: HomeAssistant) -> None: +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" dhcp_data = dhcp.DhcpServiceInfo( ip=TEST_HOST, @@ -373,8 +379,44 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: } -async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: - """Test dhcp discovery aborts if already configured.""" +@pytest.mark.parametrize( + ("last_update_success", "attr", "value", "expected"), + [ + ( + False, + None, + None, + TEST_HOST2, + ), + ( + True, + None, + None, + TEST_HOST, + ), + ( + False, + "get_state", + AsyncMock(side_effect=ReolinkError("Test error")), + TEST_HOST, + ), + ( + False, + "mac_address", + "aa:aa:aa:aa:aa:aa", + TEST_HOST, + ), + ], +) +async def test_dhcp_ip_update( + hass: HomeAssistant, + reolink_connect: MagicMock, + last_update_success: bool, + attr: str, + value: Any, + expected: str, +) -> None: + """Test dhcp discovery aborts if already configured where the IP is updated if appropriate.""" config_entry = MockConfigEntry( domain=const.DOMAIN, unique_id=format_mac(TEST_MAC), @@ -394,16 +436,31 @@ async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + if not last_update_success: + # ensure the last_update_succes is False for the device_coordinator. + reolink_connect.get_states = AsyncMock(side_effect=ReolinkError("Test error")) + async_fire_time_changed( + hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(minutes=1) + ) + await hass.async_block_till_done() dhcp_data = dhcp.DhcpServiceInfo( - ip=TEST_HOST, + ip=TEST_HOST2, hostname="Reolink", macaddress=TEST_MAC, ) + if attr is not None: + setattr(reolink_connect, attr, value) + result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert config_entry.data[CONF_HOST] == expected diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index f5f581760c1..e2bd622bb43 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,17 +1,23 @@ """Test the Reolink init.""" +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from reolink_aio.exceptions import ReolinkError -from homeassistant.components.reolink import const +from homeassistant.components.reolink import FIRMWARE_UPDATE_INTERVAL, const from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from .conftest import TEST_NVR_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") @@ -44,17 +50,11 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") Mock(return_value=False), ConfigEntryState.LOADED, ), - ( - "check_new_firmware", - AsyncMock(side_effect=ReolinkError("Test error")), - ConfigEntryState.LOADED, - ), ], ) async def test_failures_parametrized( hass: HomeAssistant, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, @@ -70,11 +70,36 @@ async def test_failures_parametrized( assert config_entry.state == expected +async def test_firmware_error_twice( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test when the firmware update fails 2 times.""" + reolink_connect.check_new_firmware = AsyncMock( + side_effect=ReolinkError("Test error") + ) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_firmware" + assert hass.states.is_state(entity_id, STATE_OFF) + + async_fire_time_changed( + hass, utcnow() + FIRMWARE_UPDATE_INTERVAL + timedelta(minutes=1) + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + async def test_entry_reloading( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -91,7 +116,7 @@ async def test_entry_reloading( async def test_no_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test no repairs issue is raised when http local url is used.""" await async_process_ha_core_config( @@ -106,10 +131,11 @@ async def test_no_repair_issue( assert (const.DOMAIN, "webhook_url") not in issue_registry.issues assert (const.DOMAIN, "enable_port") not in issue_registry.issues assert (const.DOMAIN, "firmware_update") not in issue_registry.issues + assert (const.DOMAIN, "ssl") not in issue_registry.issues async def test_https_repair_issue( - hass: HomeAssistant, config_entry: MockConfigEntry, reolink_ONVIF_wait: MagicMock + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test repairs issue is raised when https local url is used.""" await async_process_ha_core_config( @@ -130,12 +156,36 @@ async def test_https_repair_issue( assert (const.DOMAIN, "https_webhook") in issue_registry.issues +async def test_ssl_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test repairs issue is raised when global ssl certificate is used.""" + assert await async_setup_component(hass, "webhook", {}) + hass.config.api.use_ssl = True + + await async_process_ha_core_config( + hass, {"country": "GB", "internal_url": "http://test_homeassistant_address"} + ) + + with patch( + "homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0 + ), patch( + "homeassistant.components.reolink.host.FIRST_ONVIF_LONG_POLL_TIMEOUT", new=0 + ), patch( + "homeassistant.components.reolink.host.ReolinkHost._async_long_polling", + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "ssl") in issue_registry.issues + + @pytest.mark.parametrize("protocol", ["rtsp", "rtmp"]) async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, protocol: str, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" @@ -173,7 +223,6 @@ async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock, - reolink_ONVIF_wait: MagicMock, ) -> None: """Test firmware issue is raised when too old firmware is used.""" reolink_connect.sw_version_update_required = True diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index c82337b484f..6c9b51a7cf6 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -313,6 +313,7 @@ async def test_fix_issue( "flow_id": ANY, "handler": domain, "last_step": None, + "preview": None, "step_id": step, "type": "form", } diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 86bac75de91..896f5544d93 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -500,3 +500,27 @@ async def test_entity_config(hass: HomeAssistant) -> None: "friendly_name": "REST Binary Sensor", "icon": "mdi:one_two_three", } + + +@respx.mock +async def test_availability_in_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + BINARY_SENSOR_DOMAIN: { + # REST configuration + "platform": DOMAIN, + "method": "GET", + "resource": "http://localhost", + # Entity configuration + "availability": "{{value==1}}", + "name": "{{'REST' + ' ' + 'Binary Sensor'}}", + }, + } + + respx.get("http://localhost") % HTTPStatus.OK + assert await async_setup_component(hass, BINARY_SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.rest_binary_sensor") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index a7674937ab8..34e7233d33c 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONTENT_TYPE_JSON, SERVICE_RELOAD, + STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation, UnitOfTemperature, @@ -1018,3 +1019,27 @@ async def test_entity_config(hass: HomeAssistant) -> None: "state_class": "measurement", "unit_of_measurement": "°C", } + + +@respx.mock +async def test_availability_in_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + SENSOR_DOMAIN: { + # REST configuration + "platform": DOMAIN, + "method": "GET", + "resource": "http://localhost", + # Entity configuration + "availability": "{{value==1}}", + "name": "{{'REST' + ' ' + 'Sensor'}}", + }, + } + + respx.get("http://localhost").respond(status_code=HTTPStatus.OK, text="123") + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.rest_sensor") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index a6895183d4e..d57cd41aa10 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template_entity import CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import CONF_PICTURE from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -111,7 +111,7 @@ async def test_setup_minimum(hass: HomeAssistant) -> None: with assert_setup_component(1, SWITCH_DOMAIN): assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 @respx.mock @@ -129,7 +129,7 @@ async def test_setup_query_params(hass: HomeAssistant) -> None: assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 @respx.mock @@ -148,7 +148,7 @@ async def test_setup(hass: HomeAssistant) -> None: } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 assert_setup_component(1, SWITCH_DOMAIN) @@ -170,7 +170,7 @@ async def test_setup_with_state_resource(hass: HomeAssistant) -> None: } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 assert_setup_component(1, SWITCH_DOMAIN) @@ -195,7 +195,7 @@ async def test_setup_with_templated_headers_params(hass: HomeAssistant) -> None: } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert route.call_count == 1 + assert route.call_count == 2 last_call = route.calls[-1] last_request: httpx.Request = last_call.request assert last_request.headers.get("Accept") == CONTENT_TYPE_JSON diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py index 087a6840c59..6b2431fb763 100644 --- a/tests/components/rfxtrx/test_device_action.py +++ b/tests/components/rfxtrx/test_device_action.py @@ -16,10 +16,7 @@ from homeassistant.setup import async_setup_component from .conftest import create_rfx_test_cfg -from tests.common import ( - MockConfigEntry, - async_get_device_automations, -) +from tests.common import MockConfigEntry, async_get_device_automations class DeviceTestData(NamedTuple): diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index 87ca00c37c3..651c2a96388 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -56,7 +56,12 @@ def client_fixture(account): @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id=config[CONF_USERNAME], data=config) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=config[CONF_USERNAME], + data=config, + entry_id="11554ec901379b9cc8f5a6c1d11ce978", + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a98374d2941 --- /dev/null +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': list([ + dict({ + '_async_request': None, + 'event_id': 'event_123', + 'pickup_date': dict({ + '__type': "", + 'isoformat': '2022-01-24', + }), + 'pickups': list([ + dict({ + 'category': dict({ + '__type': "", + 'repr': "", + }), + 'name': 'Plastic Film', + 'offer_id': 'offer_123', + 'priority': 1, + 'product_id': 'product_123', + 'quantity': 1, + }), + ]), + 'state': dict({ + '__type': "", + 'repr': "", + }), + }), + ]), + 'entry': dict({ + 'data': dict({ + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ridwell', + 'entry_id': '11554ec901379b9cc8f5a6c1d11ce978', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 2, + }), + }) +# --- diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index e73b352f3d9..c87004a8e76 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Ridwell diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,47 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 2, - "domain": "ridwell", - "title": REDACTED, - "data": {"username": REDACTED, "password": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": [ - { - "_async_request": None, - "event_id": "event_123", - "pickup_date": { - "__type": "", - "isoformat": "2022-01-24", - }, - "pickups": [ - { - "name": "Plastic Film", - "offer_id": "offer_123", - "priority": 1, - "product_id": "product_123", - "quantity": 1, - "category": { - "__type": "", - "repr": "", - }, - } - ], - "state": { - "__type": "", - "repr": "", - }, - } - ], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 2f297135d15..bbaa8935461 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -10,9 +10,10 @@ from roborock.exceptions import ( RoborockUrlException, ) -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL @@ -28,7 +29,7 @@ async def test_config_flow_success( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" @@ -37,7 +38,7 @@ async def test_config_flow_success( result["flow_id"], {"username": USER_EMAIL} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "code" assert result["errors"] == {} with patch( @@ -48,7 +49,7 @@ async def test_config_flow_success( result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -81,7 +82,7 @@ async def test_config_flow_failures_request_code( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code", @@ -90,7 +91,7 @@ async def test_config_flow_failures_request_code( result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": USER_EMAIL} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == request_code_errors # Recover from error with patch( @@ -100,7 +101,7 @@ async def test_config_flow_failures_request_code( result["flow_id"], {"username": USER_EMAIL} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "code" assert result["errors"] == {} with patch( @@ -111,7 +112,7 @@ async def test_config_flow_failures_request_code( result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] @@ -142,7 +143,7 @@ async def test_config_flow_failures_code_login( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch( "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" @@ -151,7 +152,7 @@ async def test_config_flow_failures_code_login( result["flow_id"], {"username": USER_EMAIL} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "code" assert result["errors"] == {} # Raise exception for invalid code @@ -162,7 +163,7 @@ async def test_config_flow_failures_code_login( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == code_login_errors with patch( "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", @@ -172,7 +173,7 @@ async def test_config_flow_failures_code_login( result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == USER_EMAIL assert result["data"] == MOCK_CONFIG assert result["result"] diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index f9f3d327d29..19648343bb4 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 10 + assert len(hass.states.async_all("sensor")) == 11 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -37,3 +37,4 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non ) assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" diff --git a/tests/components/roku/conftest.py b/tests/components/roku/conftest.py index 677a10c697c..c1ceb23934e 100644 --- a/tests/components/roku/conftest.py +++ b/tests/components/roku/conftest.py @@ -81,9 +81,13 @@ def mock_roku( @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_roku: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device: RokuDevice, + mock_roku: MagicMock, ) -> MockConfigEntry: """Set up the Roku integration for testing.""" + mock_config_entry.unique_id = mock_device.info.serial_number mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/roku/fixtures/roku3-diagnostics-data.json b/tests/components/roku/fixtures/roku3-diagnostics-data.json deleted file mode 100644 index a3084b010c9..00000000000 --- a/tests/components/roku/fixtures/roku3-diagnostics-data.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "app": { - "app_id": null, - "name": "Roku", - "screensaver": false, - "version": null - }, - "apps": [ - { - "app_id": "11", - "name": "Roku Channel Store", - "screensaver": false, - "version": null - }, - { - "app_id": "12", - "name": "Netflix", - "screensaver": false, - "version": null - }, - { - "app_id": "13", - "name": "Amazon Video on Demand", - "screensaver": false, - "version": null - }, - { - "app_id": "14", - "name": "MLB.TV®", - "screensaver": false, - "version": null - }, - { - "app_id": "26", - "name": "Free FrameChannel Service", - "screensaver": false, - "version": null - }, - { - "app_id": "27", - "name": "Mediafly", - "screensaver": false, - "version": null - }, - { - "app_id": "28", - "name": "Pandora", - "screensaver": false, - "version": null - }, - { - "app_id": "74519", - "name": "Pluto TV - It's Free TV", - "screensaver": false, - "version": "5.2.0" - } - ], - "channel": null, - "channels": [], - "info": { - "brand": "Roku", - "device_location": null, - "device_type": "box", - "ethernet_mac": "b0:a7:37:96:4d:fa", - "ethernet_support": true, - "headphones_connected": false, - "model_name": "Roku 3", - "model_number": "4200X", - "name": "My Roku 3", - "network_name": null, - "network_type": "ethernet", - "serial_number": "1GU48T017973", - "supports_airplay": false, - "supports_find_remote": false, - "supports_private_listening": false, - "supports_wake_on_wlan": false, - "version": "7.5.0", - "wifi_mac": "b0:a7:37:96:4d:fb" - }, - "media": null, - "state": { - "at": "2022-01-23T21:05:03.154737", - "available": true, - "standby": false - } -} diff --git a/tests/components/roku/snapshots/test_diagnostics.ambr b/tests/components/roku/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1742d1f7ee0 --- /dev/null +++ b/tests/components/roku/snapshots/test_diagnostics.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'app': dict({ + 'app_id': None, + 'name': 'Roku', + 'screensaver': False, + 'version': None, + }), + 'apps': list([ + dict({ + 'app_id': '11', + 'name': 'Roku Channel Store', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '12', + 'name': 'Netflix', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '13', + 'name': 'Amazon Video on Demand', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '14', + 'name': 'MLB.TV®', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '26', + 'name': 'Free FrameChannel Service', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '27', + 'name': 'Mediafly', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '28', + 'name': 'Pandora', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '74519', + 'name': "Pluto TV - It's Free TV", + 'screensaver': False, + 'version': '5.2.0', + }), + ]), + 'channel': None, + 'channels': list([ + ]), + 'info': dict({ + 'brand': 'Roku', + 'device_location': None, + 'device_type': 'box', + 'ethernet_mac': 'b0:a7:37:96:4d:fa', + 'ethernet_support': True, + 'headphones_connected': False, + 'model_name': 'Roku 3', + 'model_number': '4200X', + 'name': 'My Roku 3', + 'network_name': None, + 'network_type': 'ethernet', + 'serial_number': '1GU48T017973', + 'supports_airplay': False, + 'supports_find_remote': False, + 'supports_private_listening': False, + 'supports_wake_on_wlan': False, + 'version': '7.5.0', + 'wifi_mac': 'b0:a7:37:96:4d:fb', + }), + 'media': None, + 'state': dict({ + 'at': '2023-08-15T17:00:00+00:00', + 'available': True, + 'standby': False, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '192.168.1.160', + }), + 'unique_id': '1GU48T017973', + }), + }) +# --- diff --git a/tests/components/roku/test_diagnostics.py b/tests/components/roku/test_diagnostics.py index 860d0424624..708e6d3f5e3 100644 --- a/tests/components/roku/test_diagnostics.py +++ b/tests/components/roku/test_diagnostics.py @@ -1,9 +1,11 @@ """Tests for the diagnostics data provided by the Roku integration.""" -import json +from rokuecp import Device as RokuDevice +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -11,27 +13,14 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + mock_device: RokuDevice, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for config entry.""" - diagnostics_data = json.loads(load_fixture("roku/roku3-diagnostics-data.json")) + mock_device.state.at = dt_util.parse_datetime("2023-08-15 17:00:00-00:00") - result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - - assert isinstance(result, dict) - assert isinstance(result["entry"], dict) - assert result["entry"]["data"] == {"host": "192.168.1.160"} - assert result["entry"]["unique_id"] == "1GU48T017973" - - assert isinstance(result["data"], dict) - assert result["data"]["app"] == diagnostics_data["app"] - assert result["data"]["apps"] == diagnostics_data["apps"] - assert result["data"]["channel"] == diagnostics_data["channel"] - assert result["data"]["channels"] == diagnostics_data["channels"] - assert result["data"]["info"] == diagnostics_data["info"] - assert result["data"]["media"] == diagnostics_data["media"] - - data_state = result["data"]["state"] - assert isinstance(data_state, dict) - assert data_state["available"] == diagnostics_data["state"]["available"] - assert data_state["standby"] == diagnostics_data["state"]["standby"] + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py index f8820e711a2..2ebad1e0b2b 100644 --- a/tests/components/roku/test_init.py +++ b/tests/components/roku/test_init.py @@ -26,6 +26,25 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_entry_no_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roku: AsyncMock, +) -> None: + """Test the Roku configuration entry with missing unique id.""" + mock_config_entry.unique_id = None + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + hass.data[DOMAIN][mock_config_entry.entry_id].device_id + == mock_config_entry.entry_id + ) + + async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py index 7cfb6f7f7c9..ab7b9ac00f5 100644 --- a/tests/components/roku/test_sensor.py +++ b/tests/components/roku/test_sensor.py @@ -34,7 +34,7 @@ async def test_roku_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_active_app" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == "Roku" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active app" assert state.attributes.get(ATTR_ICON) == "mdi:application" assert ATTR_DEVICE_CLASS not in state.attributes @@ -45,7 +45,7 @@ async def test_roku_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_active_app_id" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App ID" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active app ID" assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" assert ATTR_DEVICE_CLASS not in state.attributes @@ -83,7 +83,7 @@ async def test_rokutv_sensors( assert entry.unique_id == "YN00H5555555_active_app" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == "Antenna TV" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App' + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active app' assert state.attributes.get(ATTR_ICON) == "mdi:application" assert ATTR_DEVICE_CLASS not in state.attributes @@ -94,7 +94,7 @@ async def test_rokutv_sensors( assert entry.unique_id == "YN00H5555555_active_app_id" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == "tvinput.dtv" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App ID' + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active app ID' assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/ruckus_unleashed/__init__.py b/tests/components/ruckus_unleashed/__init__.py index 5c50f845064..06ad2352988 100644 --- a/tests/components/ruckus_unleashed/__init__.py +++ b/tests/components/ruckus_unleashed/__init__.py @@ -1,45 +1,61 @@ """Tests for the Ruckus Unleashed integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from aioruckus import AjaxSession, RuckusAjaxApi -from homeassistant.components.ruckus_unleashed import DOMAIN from homeassistant.components.ruckus_unleashed.const import ( - API_ACCESS_POINT, - API_AP, - API_DEVICE_NAME, - API_ID, - API_IP, - API_MAC, - API_MODEL, - API_NAME, - API_SERIAL, - API_SYSTEM_OVERVIEW, - API_VERSION, + API_AP_DEVNAME, + API_AP_MAC, + API_AP_MODEL, + API_AP_SERIALNUMBER, + API_CLIENT_AP_MAC, + API_CLIENT_HOSTNAME, + API_CLIENT_IP, + API_CLIENT_MAC, + API_MESH_NAME, + API_MESH_PSK, + API_SYS_IDENTITY, + API_SYS_IDENTITY_NAME, + API_SYS_SYSINFO, + API_SYS_SYSINFO_SERIAL, + API_SYS_SYSINFO_VERSION, + API_SYS_UNLEASHEDNETWORK, + API_SYS_UNLEASHEDNETWORK_TOKEN, + DOMAIN, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry -DEFAULT_TITLE = "Ruckus Mesh" -DEFAULT_UNIQUE_ID = "123456789012" DEFAULT_SYSTEM_INFO = { - API_SYSTEM_OVERVIEW: { - API_SERIAL: DEFAULT_UNIQUE_ID, - API_VERSION: "v1.0.0", - } + API_SYS_IDENTITY: {API_SYS_IDENTITY_NAME: "RuckusUnleashed"}, + API_SYS_SYSINFO: { + API_SYS_SYSINFO_SERIAL: "123456789012", + API_SYS_SYSINFO_VERSION: "200.7.10.202 build 141", + }, + API_SYS_UNLEASHEDNETWORK: { + API_SYS_UNLEASHEDNETWORK_TOKEN: "un1234567890121680060227001" + }, } -DEFAULT_AP_INFO = { - API_AP: { - API_ID: { - "1": { - API_MAC: "00:11:22:33:44:55", - API_DEVICE_NAME: "Test Device", - API_MODEL: "r510", - } - } - } + +DEFAULT_MESH_INFO = { + API_MESH_NAME: "Ruckus Mesh", + API_MESH_PSK: "", } +DEFAULT_AP_INFO = [ + { + API_AP_MAC: "00:11:22:33:44:55", + API_AP_DEVNAME: "Test Device", + API_AP_MODEL: "r510", + API_AP_SERIALNUMBER: DEFAULT_SYSTEM_INFO[API_SYS_SYSINFO][ + API_SYS_SYSINFO_SERIAL + ], + } +] + CONFIG = { CONF_HOST: "1.1.1.1", CONF_USERNAME: "test-username", @@ -48,53 +64,134 @@ CONFIG = { TEST_CLIENT_ENTITY_ID = "device_tracker.ruckus_test_device" TEST_CLIENT = { - API_IP: "1.1.1.2", - API_MAC: "AA:BB:CC:DD:EE:FF", - API_NAME: "Ruckus Test Device", - API_ACCESS_POINT: "00:11:22:33:44:55", + API_CLIENT_IP: "1.1.1.2", + API_CLIENT_MAC: "AA:BB:CC:DD:EE:FF", + API_CLIENT_HOSTNAME: "Ruckus Test Device", + API_CLIENT_AP_MAC: DEFAULT_AP_INFO[0][API_AP_MAC], } +DEFAULT_TITLE = DEFAULT_MESH_INFO[API_MESH_NAME] +DEFAULT_UNIQUEID = DEFAULT_SYSTEM_INFO[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] + def mock_config_entry() -> MockConfigEntry: """Return a Ruckus Unleashed mock config entry.""" return MockConfigEntry( domain=DOMAIN, title=DEFAULT_TITLE, - unique_id=DEFAULT_UNIQUE_ID, + unique_id=DEFAULT_UNIQUEID, data=CONFIG, options=None, ) -async def init_integration(hass) -> MockConfigEntry: +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the Ruckus Unleashed integration in Home Assistant.""" entry = mock_config_entry() entry.add_to_hass(hass) # Make device tied to other integration so device tracker entities get enabled + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) dr.async_get(hass).async_get_or_create( name="Device from other integration", - config_entry_id=MockConfigEntry().entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, TEST_CLIENT[API_MAC])}, + config_entry_id=other_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, TEST_CLIENT[API_CLIENT_MAC])}, ) - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - return_value=None, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", - return_value=DEFAULT_TITLE, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.system_info", - return_value=DEFAULT_SYSTEM_INFO, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.ap_info", - return_value=DEFAULT_AP_INFO, - ), patch( - "homeassistant.components.ruckus_unleashed.RuckusUnleashedDataUpdateCoordinator._fetch_clients", - return_value={ - TEST_CLIENT[API_MAC]: TEST_CLIENT, - }, - ): + + with RuckusAjaxApiPatchContext(): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry + + +class RuckusAjaxApiPatchContext: + """Context Manager which mocks the Ruckus AjaxSession and RuckusAjaxApi.""" + + def __init__( + self, + login_mock: AsyncMock = None, + system_info: dict | None = None, + mesh_info: dict | None = None, + active_clients: list[dict] | AsyncMock | None = None, + ) -> None: + """Initialize Ruckus Mock Context Manager.""" + self.login_mock = login_mock + self.system_info = system_info + self.mesh_info = mesh_info + self.active_clients = active_clients + self.patchers = [] + + def __enter__(self): + """Patch RuckusAjaxApi and AjaxSession methods.""" + self.patchers.append( + patch.object(RuckusAjaxApi, "_get_conf", new=AsyncMock(return_value={})) + ) + self.patchers.append( + patch.object( + RuckusAjaxApi, "get_aps", new=AsyncMock(return_value=DEFAULT_AP_INFO) + ) + ) + self.patchers.append( + patch.object( + RuckusAjaxApi, + "get_system_info", + new=AsyncMock( + return_value=DEFAULT_SYSTEM_INFO + if self.system_info is None + else self.system_info + ), + ) + ) + self.patchers.append( + patch.object( + RuckusAjaxApi, + "get_mesh_info", + new=AsyncMock( + return_value=DEFAULT_MESH_INFO + if self.mesh_info is None + else self.mesh_info + ), + ) + ) + self.patchers.append( + patch.object( + RuckusAjaxApi, + "get_active_clients", + new=self.active_clients + if isinstance(self.active_clients, AsyncMock) + else AsyncMock( + return_value=[TEST_CLIENT] + if self.active_clients is None + else self.active_clients + ), + ) + ) + self.patchers.append( + patch.object( + AjaxSession, + "login", + new=self.login_mock or AsyncMock(return_value=self), + ) + ) + self.patchers.append( + patch.object(AjaxSession, "close", new=AsyncMock(return_value=None)) + ) + + def _patched_async_create( + host: str, username: str, password: str + ) -> "AjaxSession": + return AjaxSession(None, host, username, password) + + self.patchers.append( + patch.object(AjaxSession, "async_create", new=_patched_async_create) + ) + + for patcher in self.patchers: + patcher.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Remove RuckusAjaxApi and AjaxSession patches.""" + for patcher in self.patchers: + patcher.stop() diff --git a/tests/components/ruckus_unleashed/test_config_flow.py b/tests/components/ruckus_unleashed/test_config_flow.py index a78c839cf6b..c55d531b0cb 100644 --- a/tests/components/ruckus_unleashed/test_config_flow.py +++ b/tests/components/ruckus_unleashed/test_config_flow.py @@ -1,15 +1,21 @@ """Test the Ruckus Unleashed config flow.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from pyruckus.exceptions import AuthenticationError +from aioruckus.const import ( + ERROR_CONNECT_TEMPORARY, + ERROR_CONNECT_TIMEOUT, + ERROR_LOGIN_INCORRECT, +) +from aioruckus.exceptions import AuthenticationError -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.ruckus_unleashed.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import utcnow -from . import CONFIG, DEFAULT_SYSTEM_INFO, DEFAULT_TITLE +from . import CONFIG, DEFAULT_TITLE, RuckusAjaxApiPatchContext, mock_config_entry from tests.common import async_fire_time_changed @@ -22,16 +28,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - return_value=None, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", - return_value=DEFAULT_TITLE, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.system_info", - return_value=DEFAULT_SYSTEM_INFO, - ), patch( + with RuckusAjaxApiPatchContext(), patch( "homeassistant.components.ruckus_unleashed.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -41,10 +38,10 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["title"] == DEFAULT_TITLE - assert result2["data"] == CONFIG - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == "create_entry" + assert result2["title"] == DEFAULT_TITLE + assert result2["data"] == CONFIG + assert len(mock_setup_entry.mock_calls) == 1 async def test_form_invalid_auth(hass: HomeAssistant) -> None: @@ -53,9 +50,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - side_effect=AuthenticationError, + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=AuthenticationError(ERROR_LOGIN_INCORRECT)) ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -66,15 +62,44 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "invalid_auth"} +async def test_form_user_reauth(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + + async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - side_effect=ConnectionError, + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=ConnectionError(ERROR_CONNECT_TIMEOUT)) ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -85,15 +110,16 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unknown_error(hass: HomeAssistant) -> None: +async def test_form_unexpected_response(hass: HomeAssistant) -> None: """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - side_effect=Exception, + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock( + side_effect=ConnectionRefusedError(ERROR_CONNECT_TEMPORARY) + ) ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -101,7 +127,7 @@ async def test_form_unknown_error(hass: HomeAssistant) -> None: ) assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"] == {"base": "cannot_connect"} async def test_form_cannot_connect_unknown_serial(hass: HomeAssistant) -> None: @@ -112,16 +138,7 @@ async def test_form_cannot_connect_unknown_serial(hass: HomeAssistant) -> None: assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - return_value=None, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", - return_value=DEFAULT_TITLE, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.system_info", - return_value={}, - ): + with RuckusAjaxApiPatchContext(system_info={}): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, @@ -137,16 +154,7 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - return_value=None, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", - return_value=DEFAULT_TITLE, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.system_info", - return_value=DEFAULT_SYSTEM_INFO, - ): + with RuckusAjaxApiPatchContext(): await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py index dc57d46715c..403ea7d0ca7 100644 --- a/tests/components/ruckus_unleashed/test_device_tracker.py +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -1,8 +1,11 @@ """The sensor tests for the Ruckus Unleashed platform.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock -from homeassistant.components.ruckus_unleashed import API_MAC, DOMAIN +from aioruckus.const import ERROR_CONNECT_EOF, ERROR_LOGIN_INCORRECT +from aioruckus.exceptions import AuthenticationError + +from homeassistant.components.ruckus_unleashed import DOMAIN from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -10,12 +13,9 @@ from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import utcnow from . import ( - DEFAULT_AP_INFO, - DEFAULT_SYSTEM_INFO, - DEFAULT_TITLE, - DEFAULT_UNIQUE_ID, - TEST_CLIENT, + DEFAULT_UNIQUEID, TEST_CLIENT_ENTITY_ID, + RuckusAjaxApiPatchContext, init_integration, mock_config_entry, ) @@ -28,12 +28,7 @@ async def test_client_connected(hass: HomeAssistant) -> None: await init_integration(hass) future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.ruckus_unleashed.RuckusUnleashedDataUpdateCoordinator._fetch_clients", - return_value={ - TEST_CLIENT[API_MAC]: TEST_CLIENT, - }, - ): + with RuckusAjaxApiPatchContext(): async_fire_time_changed(hass, future) await hass.async_block_till_done() await async_update_entity(hass, TEST_CLIENT_ENTITY_ID) @@ -47,10 +42,7 @@ async def test_client_disconnected(hass: HomeAssistant) -> None: await init_integration(hass) future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.ruckus_unleashed.RuckusUnleashedDataUpdateCoordinator._fetch_clients", - return_value={}, - ): + with RuckusAjaxApiPatchContext(active_clients={}): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -64,9 +56,24 @@ async def test_clients_update_failed(hass: HomeAssistant) -> None: await init_integration(hass) future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.ruckus_unleashed.RuckusUnleashedDataUpdateCoordinator._fetch_clients", - side_effect=ConnectionError, + with RuckusAjaxApiPatchContext( + active_clients=AsyncMock(side_effect=ConnectionError(ERROR_CONNECT_EOF)) + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + await async_update_entity(hass, TEST_CLIENT_ENTITY_ID) + test_client = hass.states.get(TEST_CLIENT_ENTITY_ID) + assert test_client.state == STATE_UNAVAILABLE + + +async def test_clients_update_auth_failed(hass: HomeAssistant) -> None: + """Test failed update with bad auth.""" + await init_integration(hass) + + future = utcnow() + timedelta(minutes=60) + with RuckusAjaxApiPatchContext( + active_clients=AsyncMock(side_effect=AuthenticationError(ERROR_LOGIN_INCORRECT)) ): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -85,27 +92,12 @@ async def test_restoring_clients(hass: HomeAssistant) -> None: registry.async_get_or_create( "device_tracker", DOMAIN, - DEFAULT_UNIQUE_ID, + DEFAULT_UNIQUEID, suggested_object_id="ruckus_test_device", config_entry=entry, ) - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - return_value=None, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", - return_value=DEFAULT_TITLE, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.system_info", - return_value=DEFAULT_SYSTEM_INFO, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.ap_info", - return_value=DEFAULT_AP_INFO, - ), patch( - "homeassistant.components.ruckus_unleashed.RuckusUnleashedDataUpdateCoordinator._fetch_clients", - return_value={}, - ): + 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/ruckus_unleashed/test_init.py b/tests/components/ruckus_unleashed/test_init.py index 0b32b27517d..c8246a5ac1e 100644 --- a/tests/components/ruckus_unleashed/test_init.py +++ b/tests/components/ruckus_unleashed/test_init.py @@ -1,18 +1,16 @@ """Test the Ruckus Unleashed config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock -from pyruckus.exceptions import AuthenticationError +from aioruckus.const import ERROR_CONNECT_TIMEOUT, ERROR_LOGIN_INCORRECT +from aioruckus.exceptions import AuthenticationError -from homeassistant.components.ruckus_unleashed import ( - API_AP, - API_DEVICE_NAME, - API_ID, - API_MAC, - API_MODEL, - API_SYSTEM_OVERVIEW, - API_VERSION, - DOMAIN, - MANUFACTURER, +from homeassistant.components.ruckus_unleashed import DOMAIN, MANUFACTURER +from homeassistant.components.ruckus_unleashed.const import ( + API_AP_DEVNAME, + API_AP_MAC, + API_AP_MODEL, + API_SYS_SYSINFO, + API_SYS_SYSINFO_VERSION, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -22,7 +20,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from . import ( DEFAULT_AP_INFO, DEFAULT_SYSTEM_INFO, - DEFAULT_TITLE, + RuckusAjaxApiPatchContext, init_integration, mock_config_entry, ) @@ -31,9 +29,8 @@ from . import ( async def test_setup_entry_login_error(hass: HomeAssistant) -> None: """Test entry setup failed due to login error.""" entry = mock_config_entry() - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - side_effect=AuthenticationError, + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=AuthenticationError(ERROR_LOGIN_INCORRECT)) ): entry.add_to_hass(hass) result = await hass.config_entries.async_setup(entry.entry_id) @@ -45,9 +42,8 @@ async def test_setup_entry_login_error(hass: HomeAssistant) -> None: async def test_setup_entry_connection_error(hass: HomeAssistant) -> None: """Test entry setup failed due to connection error.""" entry = mock_config_entry() - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - side_effect=ConnectionError, + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=ConnectionError(ERROR_CONNECT_TIMEOUT)) ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -60,19 +56,22 @@ async def test_router_device_setup(hass: HomeAssistant) -> None: """Test a router device is created.""" await init_integration(hass) - device_info = DEFAULT_AP_INFO[API_AP][API_ID]["1"] + device_info = DEFAULT_AP_INFO[0] device_registry = dr.async_get(hass) device = device_registry.async_get_device( - identifiers={(CONNECTION_NETWORK_MAC, device_info[API_MAC])}, - connections={(CONNECTION_NETWORK_MAC, device_info[API_MAC])}, + identifiers={(CONNECTION_NETWORK_MAC, device_info[API_AP_MAC])}, + connections={(CONNECTION_NETWORK_MAC, device_info[API_AP_MAC])}, ) assert device assert device.manufacturer == MANUFACTURER - assert device.model == device_info[API_MODEL] - assert device.name == device_info[API_DEVICE_NAME] - assert device.sw_version == DEFAULT_SYSTEM_INFO[API_SYSTEM_OVERVIEW][API_VERSION] + assert device.model == device_info[API_AP_MODEL] + assert device.name == device_info[API_AP_DEVNAME] + assert ( + device.sw_version + == DEFAULT_SYSTEM_INFO[API_SYS_SYSINFO][API_SYS_SYSINFO_VERSION] + ) assert device.via_device_id is None @@ -83,31 +82,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() + with RuckusAjaxApiPatchContext(): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) - - -async def test_config_not_ready_during_setup(hass: HomeAssistant) -> None: - """Test we throw a ConfigNotReady if Coordinator update fails.""" - entry = mock_config_entry() - with patch( - "homeassistant.components.ruckus_unleashed.Ruckus.connect", - return_value=None, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", - return_value=DEFAULT_TITLE, - ), patch( - "homeassistant.components.ruckus_unleashed.Ruckus.system_info", - return_value=DEFAULT_SYSTEM_INFO, - ), patch( - "homeassistant.components.ruckus_unleashed.RuckusUnleashedDataUpdateCoordinator._async_update_data", - side_effect=ConnectionError, - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/schlage/__init__.py b/tests/components/schlage/__init__.py new file mode 100644 index 00000000000..c6cd3fec0bc --- /dev/null +++ b/tests/components/schlage/__init__.py @@ -0,0 +1 @@ +"""Tests for the Schlage integration.""" diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py new file mode 100644 index 00000000000..0078e6a5553 --- /dev/null +++ b/tests/components/schlage/conftest.py @@ -0,0 +1,88 @@ +"""Common fixtures for the Schlage tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, create_autospec, patch + +from pyschlage.lock import Lock +import pytest + +from homeassistant.components.schlage.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock ConfigEntry.""" + return MockConfigEntry( + title="asdf@asdf.com", + domain=DOMAIN, + data={ + CONF_USERNAME: "asdf@asdf.com", + CONF_PASSWORD: "hunter2", + }, + unique_id="abc123", + ) + + +@pytest.fixture +async def mock_added_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, + mock_lock: Mock, +) -> MockConfigEntry: + """Mock ConfigEntry that's been added to HA.""" + mock_schlage.locks.return_value = [mock_lock] + mock_schlage.users.return_value = [] + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert DOMAIN in hass.config_entries.async_domains() + return mock_config_entry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.schlage.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_schlage(): + """Mock pyschlage.Schlage.""" + with patch("pyschlage.Schlage", autospec=True) as mock_schlage: + yield mock_schlage.return_value + + +@pytest.fixture +def mock_pyschlage_auth(): + """Mock pyschlage.Auth.""" + with patch("pyschlage.Auth", autospec=True) as mock_auth: + mock_auth.return_value.user_id = "abc123" + yield mock_auth.return_value + + +@pytest.fixture +def mock_lock(): + """Mock Lock fixture.""" + mock_lock = create_autospec(Lock) + mock_lock.configure_mock( + device_id="test", + name="Vault Door", + model_name="", + is_locked=False, + is_jammed=False, + battery_level=20, + firmware_version="1.0", + lock_and_leave_enabled=True, + beeper_enabled=True, + ) + mock_lock.logs.return_value = [] + mock_lock.last_changed_by.return_value = "thumbturn" + return mock_lock diff --git a/tests/components/schlage/test_config_flow.py b/tests/components/schlage/test_config_flow.py new file mode 100644 index 00000000000..b256e8950ed --- /dev/null +++ b/tests/components/schlage/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Schlage config flow.""" +from unittest.mock import AsyncMock, Mock + +from pyschlage.exceptions import Error as PyschlageError, NotAuthorizedError +import pytest + +from homeassistant import config_entries +from homeassistant.components.schlage.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_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_pyschlage_auth: Mock +) -> 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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_pyschlage_auth.authenticate.assert_called_once_with() + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_pyschlage_auth: Mock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_pyschlage_auth.authenticate.side_effect = NotAuthorizedError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unknown(hass: HomeAssistant, mock_pyschlage_auth: Mock) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_pyschlage_auth.authenticate.side_effect = PyschlageError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py new file mode 100644 index 00000000000..0811d87ec80 --- /dev/null +++ b/tests/components/schlage/test_init.py @@ -0,0 +1,61 @@ +"""Tests for the Schlage integration.""" + +from unittest.mock import Mock, patch + +from pycognito.exceptions import WarrantException +from pyschlage.exceptions import Error + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@patch( + "pyschlage.Auth", + side_effect=WarrantException, +) +async def test_auth_failed( + mock_auth: Mock, hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test failed auth on 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 mock_auth.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_update_data_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, +) -> None: + """Test that we properly handle API errors.""" + mock_schlage.locks.side_effect = Error + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_schlage.locks.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyschlage_auth: Mock, + mock_schlage: Mock, +) -> None: + """Test the Schlage configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + 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 diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py new file mode 100644 index 00000000000..bf32d76836c --- /dev/null +++ b/tests/components/schlage/test_lock.py @@ -0,0 +1,87 @@ +"""Test schlage lock.""" + +from datetime import timedelta +from unittest.mock import Mock + +from pyschlage.exceptions import UnknownError + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + + +async def test_lock_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test lock is added to device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={("schlage", "test")}) + assert device.model == "" + assert device.sw_version == "1.0" + assert device.name == "Vault Door" + assert device.manufacturer == "Schlage" + + +async def test_lock_services( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test lock services.""" + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + service_data={ATTR_ENTITY_ID: "lock.vault_door"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.lock.assert_called_once_with() + + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + service_data={ATTR_ENTITY_ID: "lock.vault_door"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.unlock.assert_called_once_with() + + await hass.config_entries.async_unload(mock_added_config_entry.entry_id) + + +async def test_changed_by( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test population of the changed_by attribute.""" + mock_lock.last_changed_by.reset_mock() + mock_lock.last_changed_by.return_value = "access code - foo" + + # Make the coordinator refresh data. + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + mock_lock.last_changed_by.assert_called_once_with([]) + + lock_device = hass.states.get("lock.vault_door") + assert lock_device is not None + assert lock_device.attributes.get("changed_by") == "access code - foo" + + +async def test_changed_by_uses_previous_logs_on_failure( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test that a failure to load logs is not terminal.""" + mock_lock.last_changed_by.reset_mock() + mock_lock.last_changed_by.return_value = "thumbturn" + mock_lock.logs.side_effect = UnknownError("Cannot load logs") + + # Make the coordinator refresh data. + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + mock_lock.last_changed_by.assert_called_once_with([]) + + lock_device = hass.states.get("lock.vault_door") + assert lock_device is not None + assert lock_device.attributes.get("changed_by") == "thumbturn" diff --git a/tests/components/schlage/test_sensor.py b/tests/components/schlage/test_sensor.py new file mode 100644 index 00000000000..775438795ff --- /dev/null +++ b/tests/components/schlage/test_sensor.py @@ -0,0 +1,30 @@ +"""Test schlage sensor.""" + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_sensor_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test sensor is added to device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={("schlage", "test")}) + assert device.model == "" + assert device.sw_version == "1.0" + assert device.name == "Vault Door" + assert device.manufacturer == "Schlage" + + +async def test_battery_sensor( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test the battery sensor.""" + battery_sensor = hass.states.get("sensor.vault_door_battery") + assert battery_sensor is not None + assert battery_sensor.state == "20" + assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE + assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY diff --git a/tests/components/schlage/test_switch.py b/tests/components/schlage/test_switch.py new file mode 100644 index 00000000000..30e56b0686f --- /dev/null +++ b/tests/components/schlage/test_switch.py @@ -0,0 +1,72 @@ +"""Test schlage switch.""" +from unittest.mock import Mock + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + + +async def test_switch_device_registry( + hass: HomeAssistant, mock_added_config_entry: ConfigEntry +) -> None: + """Test switch is added to device registry.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={("schlage", "test")}) + assert device.model == "" + assert device.sw_version == "1.0" + assert device.name == "Vault Door" + assert device.manufacturer == "Schlage" + + +async def test_beeper_services( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test BeeperSwitch services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.vault_door_keypress_beep"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_beeper.assert_called_once_with(False) + mock_lock.set_beeper.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.vault_door_keypress_beep"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_beeper.assert_called_once_with(True) + + await hass.config_entries.async_unload(mock_added_config_entry.entry_id) + + +async def test_lock_and_leave_services( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test LockAndLeaveSwitch services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.vault_door_1_touch_locking"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_lock_and_leave.assert_called_once_with(False) + mock_lock.set_lock_and_leave.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.vault_door_1_touch_locking"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lock.set_lock_and_leave.assert_called_once_with(True) + + await hass.config_entries.async_unload(mock_added_config_entry.entry_id) diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index 9c6c5e0b4de..9e1895f3a58 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -22,6 +22,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, @@ -99,6 +100,68 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_with_post( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form using POST method.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.rest.RestData", + return_value=get_data, + ) as mock_data: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_PAYLOAD: "POST", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["version"] == 1 + assert result3["options"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_PAYLOAD: "POST", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + } + + assert len(mock_data.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_flow_fails( hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock ) -> None: diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index 9b6122d6010..aa4be4cdef3 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -1,7 +1,6 @@ """Test Scrape component setup process.""" from __future__ import annotations -from datetime import datetime from unittest.mock import patch import pytest @@ -11,6 +10,7 @@ from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import MockRestData, return_integration_config @@ -67,7 +67,7 @@ async def test_setup_no_data_fails_with_recovery( assert "Platform scrape not ready yet" in caplog.text mocker.payload = "test_scrape_sensor" - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index 60cde48e5bf..559c94633cd 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -1,7 +1,7 @@ """The tests for the Scrape sensor platform.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import timedelta from unittest.mock import patch import pytest @@ -28,7 +28,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, +) from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -244,7 +247,7 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: assert state.state == "Current Version: 2021.12.10" mocker.payload = "test_scrape_sensor_no_data" - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 56a7a8c902c..688a373b8f0 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -101,11 +101,7 @@ async def test_climate( "max_temp": 20, "target_temp_step": 1, "fan_modes": ["low", "medium", "quiet"], - "swing_modes": [ - "fixedmiddletop", - "fixedtop", - "stopped", - ], + "swing_modes": ["fixedmiddletop", "fixedtop", "stopped"], "current_temperature": 21.2, "temperature": 25, "current_humidity": 32.9, @@ -1336,3 +1332,56 @@ async def test_climate_full_ac_state( assert state.state == "cool" assert state.attributes["temperature"] == 22 + + +async def test_climate_fan_mode_and_swing_mode_not_supported( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate fan_mode and swing_mode not supported is raising error.""" + + state1 = hass.states.get("climate.hallway") + assert state1.attributes["fan_mode"] == "high" + assert state1.attributes["swing_mode"] == "stopped" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", + ), pytest.raises( + HomeAssistantError, + match="Climate swing mode faulty_swing_mode is not supported by the integration, please open an issue", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: state1.entity_id, ATTR_SWING_MODE: "faulty_swing_mode"}, + blocking=True, + ) + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", + ), pytest.raises( + HomeAssistantError, + match="Climate fan mode faulty_fan_mode is not supported by the integration, please open an issue", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: state1.entity_id, ATTR_FAN_MODE: "faulty_fan_mode"}, + blocking=True, + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state2 = hass.states.get("climate.hallway") + assert state2.attributes["fan_mode"] == "high" + assert state2.attributes["swing_mode"] == "stopped" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index c5406a85fc0..1f836ad9095 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Generator -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from decimal import Decimal from typing import Any @@ -177,7 +177,7 @@ async def test_datetime_conversion( enable_custom_integrations: None, ) -> None: """Test conversion of datetime.""" - test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=timezone.utc) + test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=UTC) test_local_timestamp = test_timestamp.astimezone( dt_util.get_time_zone("Europe/Amsterdam") ) @@ -233,7 +233,7 @@ async def test_a_sensor_with_a_non_numeric_device_class( A non numeric sensor with a valid device class should never be handled as numeric because it has a device class. """ - test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=timezone.utc) + test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=UTC) test_local_timestamp = test_timestamp.astimezone( dt_util.get_time_zone("Europe/Amsterdam") ) @@ -334,7 +334,7 @@ RESTORE_DATA = { "native_unit_of_measurement": None, "native_value": { "__type": "", - "isoformat": datetime(2020, 2, 8, 15, tzinfo=timezone.utc).isoformat(), + "isoformat": datetime(2020, 2, 8, 15, tzinfo=UTC).isoformat(), }, }, "Decimal": { @@ -375,7 +375,7 @@ RESTORE_DATA = { ), (date(2020, 2, 8), dict, RESTORE_DATA["date"], SensorDeviceClass.DATE, None), ( - datetime(2020, 2, 8, 15, tzinfo=timezone.utc), + datetime(2020, 2, 8, 15, tzinfo=UTC), dict, RESTORE_DATA["datetime"], SensorDeviceClass.TIMESTAMP, @@ -433,7 +433,7 @@ async def test_restore_sensor_save_state( (123.0, float, RESTORE_DATA["float"], SensorDeviceClass.TEMPERATURE, "°F"), (date(2020, 2, 8), date, RESTORE_DATA["date"], SensorDeviceClass.DATE, None), ( - datetime(2020, 2, 8, 15, tzinfo=timezone.utc), + datetime(2020, 2, 8, 15, tzinfo=UTC), datetime, RESTORE_DATA["datetime"], SensorDeviceClass.TIMESTAMP, @@ -1861,13 +1861,17 @@ async def test_device_classes_with_invalid_unit_of_measurement( ], ) @pytest.mark.parametrize( - "native_value", + ("native_value", "problem"), [ - "", - "abc", - "13.7.1", - datetime(2012, 11, 10, 7, 35, 1), - date(2012, 11, 10), + ("", "non-numeric"), + ("abc", "non-numeric"), + ("13.7.1", "non-numeric"), + (datetime(2012, 11, 10, 7, 35, 1), "non-numeric"), + (date(2012, 11, 10), "non-numeric"), + ("inf", "non-finite"), + (float("inf"), "non-finite"), + ("nan", "non-finite"), + (float("nan"), "non-finite"), ], ) async def test_non_numeric_validation_error( @@ -1875,6 +1879,7 @@ async def test_non_numeric_validation_error( caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, native_value: Any, + problem: str, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit: str | None, @@ -1899,7 +1904,7 @@ async def test_non_numeric_validation_error( assert ( "thus indicating it has a numeric value; " - f"however, it has the non-numeric value: '{native_value}'" + f"however, it has the {problem} value: '{native_value}'" ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 65b0a0b9485..1c0200e1b53 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,7 +1,5 @@ """The tests for sensor recorder platform.""" from collections.abc import Callable - -# pylint: disable=invalid-name from datetime import datetime, timedelta import math from statistics import mean diff --git a/tests/components/sensorpush/__init__.py b/tests/components/sensorpush/__init__.py index 0fe9ced64df..c281d4dc086 100644 --- a/tests/components/sensorpush/__init__.py +++ b/tests/components/sensorpush/__init__.py @@ -32,3 +32,14 @@ HTPWX_SERVICE_INFO = BluetoothServiceInfo( service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], source="local", ) + + +HTPWX_EMPTY_SERVICE_INFO = BluetoothServiceInfo( + name="SensorPush HTP.xw F4D", + address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", + rssi=-56, + manufacturer_data={}, + service_data={}, + service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"], + source="local", +) diff --git a/tests/components/sensorpush/test_sensor.py b/tests/components/sensorpush/test_sensor.py index f2d6cf6d1ac..e00b626b20b 100644 --- a/tests/components/sensorpush/test_sensor.py +++ b/tests/components/sensorpush/test_sensor.py @@ -1,17 +1,33 @@ """Test the SensorPush sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.sensorpush.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import HTPWX_SERVICE_INFO +from . import HTPWX_EMPTY_SERVICE_INFO, HTPWX_SERVICE_INFO -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" + start_monotonic = time.monotonic() entry = MockConfigEntry( domain=DOMAIN, unique_id="4125DDBA-2774-4851-9889-6AADDD4CAC3D", @@ -27,11 +43,39 @@ async def test_sensors(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 3 temp_sensor = hass.states.get("sensor.htp_xw_f4d_temperature") - temp_sensor_attribtes = temp_sensor.attributes + temp_sensor_attributes = temp_sensor.attributes + assert temp_sensor.state == "20.11" + assert temp_sensor_attributes[ATTR_FRIENDLY_NAME] == "HTP.xw F4D Temperature" + assert temp_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attributes[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.htp_xw_f4d_temperature") + assert temp_sensor.state == STATE_UNAVAILABLE + inject_bluetooth_service_info(hass, HTPWX_EMPTY_SERVICE_INFO) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.htp_xw_f4d_temperature") assert temp_sensor.state == "20.11" - assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "HTP.xw F4D Temperature" - assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "°C" - assert temp_sensor_attribtes[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/shell_command/test_init.py b/tests/components/shell_command/test_init.py index ac594c811ed..1efcc9dc919 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -174,6 +174,40 @@ async def test_stdout_captured(mock_output, hass: HomeAssistant) -> None: assert response["returncode"] == 0 +@patch("homeassistant.components.shell_command._LOGGER.debug") +async def test_non_text_stdout_capture( + mock_output, hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling of non-text output.""" + assert await async_setup_component( + hass, + shell_command.DOMAIN, + { + shell_command.DOMAIN: { + "output_image": "curl -o - https://raw.githubusercontent.com/home-assistant/assets/master/misc/loading-screen.gif" + } + }, + ) + + # No problem without 'return_response' + response = await hass.services.async_call( + "shell_command", "output_image", blocking=True + ) + + await hass.async_block_till_done() + assert not response + + # Non-text output throws with 'return_response' + with pytest.raises(UnicodeDecodeError): + response = await hass.services.async_call( + "shell_command", "output_image", blocking=True, return_response=True + ) + + await hass.async_block_till_done() + assert not response + assert "Unable to handle non-utf8 output of command" in caplog.text + + @patch("homeassistant.components.shell_command._LOGGER.debug") async def test_stderr_captured(mock_output, hass: HomeAssistant) -> None: """Test subprocess that has stderr.""" diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 67f47b0e7e3..464118ac99b 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.shelly.const import ( @@ -20,7 +21,6 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.entity_registry import async_get -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -78,17 +78,21 @@ def inject_rpc_device_event( mock_rpc_device.mock_event() -async def mock_rest_update(hass: HomeAssistant, seconds=REST_SENSORS_UPDATE_INTERVAL): +async def mock_rest_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + seconds=REST_SENSORS_UPDATE_INTERVAL, +): """Move time to create REST sensors update event.""" - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=seconds)) + freezer.tick(timedelta(seconds=seconds)) + async_fire_time_changed(hass) await hass.async_block_till_done() -async def mock_polling_rpc_update(hass: HomeAssistant): +async def mock_polling_rpc_update(hass: HomeAssistant, freezer: FrozenDateTimeFactory): """Move time to create polling RPC sensors update event.""" - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 96e888d7509..797673265a6 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -3,8 +3,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, PropertyMock, patch -from aioshelly.block_device import BlockDevice -from aioshelly.rpc_device import RpcDevice, UpdateType +from aioshelly.block_device import BlockDevice, BlockUpdateType +from aioshelly.rpc_device import RpcDevice, RpcUpdateType import pytest from homeassistant.components.shelly.const import ( @@ -131,6 +131,16 @@ MOCK_BLOCKS = [ description="emeter_0", type="emeter", ), + Mock( + sensor_ids={"valve": "closed"}, + valve="closed", + channel="0", + description="valve_0", + type="valve", + set_state=AsyncMock( + side_effect=lambda go: {"state": "opening" if go == "open" else "closing"} + ), + ), ] MOCK_CONFIG = { @@ -247,7 +257,14 @@ async def mock_block_device(): with patch("aioshelly.block_device.BlockDevice.create") as block_device_mock: def update(): - block_device_mock.return_value.subscribe_updates.call_args[0][0]({}) + block_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, BlockUpdateType.COAP_PERIODIC + ) + + def update_reply(): + block_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, BlockUpdateType.COAP_REPLY + ) device = Mock( spec=BlockDevice, @@ -263,6 +280,9 @@ async def mock_block_device(): type(device).name = PropertyMock(return_value="Test name") block_device_mock.return_value = device block_device_mock.return_value.mock_update = Mock(side_effect=update) + block_device_mock.return_value.mock_update_reply = Mock( + side_effect=update_reply + ) yield block_device_mock.return_value @@ -291,7 +311,7 @@ async def mock_pre_ble_rpc_device(): def update(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.STATUS + {}, RpcUpdateType.STATUS ) device = _mock_rpc_device("0.11.0") @@ -310,17 +330,17 @@ async def mock_rpc_device(): def update(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.STATUS + {}, RpcUpdateType.STATUS ) def event(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.EVENT + {}, RpcUpdateType.EVENT ) def disconnected(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.DISCONNECTED + {}, RpcUpdateType.DISCONNECTED ) device = _mock_rpc_device("0.12.0") diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index c067f5dffc9..8905ff5c3e8 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -1,4 +1,6 @@ """Tests for Shelly binary sensor platform.""" +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.shelly.const import SLEEP_PERIOD_MULTIPLIER from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN @@ -54,7 +56,7 @@ async def test_block_binary_sensor_extra_state_attr( async def test_block_rest_binary_sensor( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST binary sensor.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -64,13 +66,13 @@ async def test_block_rest_binary_sensor( assert hass.states.get(entity_id).state == STATE_OFF monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_ON async def test_block_rest_binary_sensor_connected_battery_devices( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST binary sensor for connected battery devices.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -84,11 +86,11 @@ async def test_block_rest_binary_sensor_connected_battery_devices( monkeypatch.setitem(mock_block_device.status["cloud"], "connected", True) # Verify no update on fast intervals - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_OFF # Verify update on slow intervals - await mock_rest_update(hass, seconds=SLEEP_PERIOD_MULTIPLIER * 3600) + await mock_rest_update(hass, freezer, seconds=SLEEP_PERIOD_MULTIPLIER * 3600) assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c806cb5e742..08ec548d3f0 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -32,6 +32,7 @@ from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data SENSOR_BLOCK_ID = 3 DEVICE_BLOCK_ID = 4 EMETER_BLOCK_ID = 5 +GAS_VALVE_BLOCK_ID = 6 ENTITY_ID = f"{CLIMATE_DOMAIN}.test_name" @@ -47,6 +48,7 @@ async def test_climate_hvac_mode( ) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") # Make device online @@ -103,6 +105,7 @@ async def test_climate_set_temperature( """Test climate set temperature service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") await init_integration(hass, 1, sleep_period=1000) # Make device online @@ -144,6 +147,7 @@ async def test_climate_set_preset_mode( ) -> None: """Test climate set preset mode service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", None) await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") @@ -198,6 +202,7 @@ async def test_block_restored_climate( ) -> None: """Test block restored climate.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) @@ -261,6 +266,7 @@ async def test_block_restored_climate_us_customery( """Test block restored climate with US CUSTOMATY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8536c3d72e6..3872f6f5a1a 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -26,7 +27,6 @@ from homeassistant.helpers.device_registry import ( async_get as async_get_dev_reg, ) import homeassistant.helpers.issue_registry as ir -from homeassistant.util import dt as dt_util from . import ( MOCK_MAC, @@ -36,7 +36,6 @@ from . import ( mock_rest_update, register_entity, ) -from .conftest import MOCK_BLOCKS from tests.common import async_fire_time_changed @@ -47,7 +46,7 @@ DEVICE_BLOCK_ID = 4 async def test_block_reload_on_cfg_change( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block reload on config change.""" await init_integration(hass, 1) @@ -67,16 +66,15 @@ async def test_block_reload_on_cfg_change( assert hass.states.get("switch.test_name_channel_1") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is None async def test_block_no_reload_on_bulb_changes( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block no reload on bulb mode/effect change.""" await init_integration(hass, 1, model="SHBLB-1") @@ -97,9 +95,8 @@ async def test_block_no_reload_on_bulb_changes( assert hass.states.get("switch.test_name_channel_1") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is not None @@ -113,16 +110,15 @@ async def test_block_no_reload_on_bulb_changes( assert hass.states.get("switch.test_name_channel_1") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_name_channel_1") is not None async def test_block_polling_auth_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device polling authentication error.""" monkeypatch.setattr( @@ -135,9 +131,8 @@ async def test_block_polling_auth_error( assert entry.state == ConfigEntryState.LOADED # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) - ) + freezer.tick(timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -155,7 +150,7 @@ async def test_block_polling_auth_error( async def test_block_rest_update_auth_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST update authentication error.""" register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -171,7 +166,7 @@ async def test_block_rest_update_auth_error( assert entry.state == ConfigEntryState.LOADED - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert entry.state == ConfigEntryState.LOADED @@ -188,7 +183,7 @@ async def test_block_rest_update_auth_error( async def test_block_polling_connection_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device polling connection error.""" monkeypatch.setattr( @@ -201,16 +196,15 @@ async def test_block_polling_connection_error( assert hass.states.get("switch.test_name_channel_1").state == STATE_ON # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) - ) + 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 async def test_block_rest_update_connection_error( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST update connection error.""" entity_id = register_entity(hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud") @@ -218,7 +212,7 @@ async def test_block_rest_update_connection_error( monkeypatch.setitem(mock_block_device.status, "uptime", 1) await init_integration(hass, 1) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_ON monkeypatch.setattr( @@ -226,13 +220,13 @@ async def test_block_rest_update_connection_error( "update_shelly", AsyncMock(side_effect=DeviceConnectionError), ) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_block_sleeping_device_no_periodic_updates( - hass: HomeAssistant, mock_block_device + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device ) -> None: """Test block sleeping device no periodic updates.""" entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" @@ -245,9 +239,8 @@ async def test_block_sleeping_device_no_periodic_updates( assert hass.states.get(entity_id).state == "22.1" # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) - ) + 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 @@ -259,24 +252,25 @@ async def test_block_device_push_updates_failure( """Test block device with push updates failure.""" issue_registry: ir.IssueRegistry = ir.async_get(hass) - monkeypatch.setattr( - mock_block_device, - "update", - AsyncMock(return_value=MOCK_BLOCKS), - ) await init_integration(hass, 1) - # Move time to force polling - for _ in range(MAX_PUSH_UPDATE_FAILURES + 1): - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) - ) + # Updates with COAP_REPLAY type should create an issue + for _ in range(MAX_PUSH_UPDATE_FAILURES): + mock_block_device.mock_update_reply() await hass.async_block_till_done() assert issue_registry.async_get_issue( domain=DOMAIN, issue_id=f"push_update_{MOCK_MAC}" ) + # An update with COAP_PERIODIC type should clear the issue + mock_block_device.mock_update() + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"push_update_{MOCK_MAC}" + ) + async def test_block_button_click_event( hass: HomeAssistant, mock_block_device, events, monkeypatch @@ -322,7 +316,7 @@ async def test_block_button_click_event( async def test_rpc_reload_on_cfg_change( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC reload on config change.""" await init_integration(hass, 2) @@ -356,16 +350,15 @@ async def test_rpc_reload_on_cfg_change( assert hass.states.get("switch.test_switch_0") is not None # Wait for debouncer - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) - ) + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_switch_0") is None async def test_rpc_reload_with_invalid_auth( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC when InvalidAuthError is raising during config entry reload.""" with patch( @@ -398,9 +391,8 @@ async def test_rpc_reload_with_invalid_auth( await hass.async_block_till_done() # Move time to generate reconnect - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -455,7 +447,7 @@ async def test_rpc_click_event( async def test_rpc_update_entry_sleep_period( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC update entry sleep period.""" entry = await init_integration(hass, 2, sleep_period=600) @@ -475,16 +467,15 @@ async def test_rpc_update_entry_sleep_period( # Move time to generate sleep period update monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER) - ) + freezer.tick(timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.data["sleep_period"] == 3600 async def test_rpc_sleeping_device_no_periodic_updates( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC sleeping device no periodic updates.""" entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" @@ -504,16 +495,15 @@ async def test_rpc_sleeping_device_no_periodic_updates( assert hass.states.get(entity_id).state == "22.9" # Move time to generate polling - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) - ) + 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 async def test_rpc_reconnect_auth_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC reconnect authentication error.""" entry = await init_integration(hass, 2) @@ -530,9 +520,8 @@ async def test_rpc_reconnect_auth_error( assert entry.state == ConfigEntryState.LOADED # Move time to generate reconnect - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -550,7 +539,7 @@ async def test_rpc_reconnect_auth_error( async def test_rpc_polling_auth_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling authentication error.""" register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -566,7 +555,7 @@ async def test_rpc_polling_auth_error( assert entry.state == ConfigEntryState.LOADED - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert entry.state == ConfigEntryState.LOADED @@ -583,7 +572,7 @@ async def test_rpc_polling_auth_error( async def test_rpc_reconnect_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC reconnect error.""" await init_integration(hass, 2) @@ -600,16 +589,15 @@ async def test_rpc_reconnect_error( ) # Move time to generate reconnect - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) - ) + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("switch.test_switch_0").state == STATE_UNAVAILABLE async def test_rpc_polling_connection_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling connection error.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -625,13 +613,13 @@ async def test_rpc_polling_connection_error( assert hass.states.get(entity_id).state == "-63" - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE async def test_rpc_polling_disconnected( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling device disconnected.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -641,6 +629,6 @@ async def test_rpc_polling_disconnected( assert hass.states.get(entity_id).state == "-63" - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 6e8c3bf8005..143501ef620 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -25,10 +25,7 @@ from homeassistant.setup import async_setup_component from . import init_integration -from tests.common import ( - MockConfigEntry, - async_get_device_automations, -) +from tests.common import MockConfigEntry, async_get_device_automations @pytest.mark.parametrize( diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index be6e319c8ac..2ead9cba198 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -3,7 +3,11 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, +) import pytest from homeassistant.components.shelly.const import ( @@ -86,6 +90,22 @@ async def test_device_connection_error( assert entry.state == ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("gen", [1, 2]) +async def test_mac_mismatch_error( + hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch +) -> None: + """Test device MAC address mismatch error.""" + monkeypatch.setattr( + mock_block_device, "initialize", AsyncMock(side_effect=MacAddressMismatchError) + ) + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=MacAddressMismatchError) + ) + + entry = await init_integration(hass, gen) + assert entry.state == ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize("gen", [1, 2]) async def test_device_auth_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index d87460fb17d..892d06ad626 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1,4 +1,6 @@ """Tests for Shelly sensor platform.""" +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( @@ -89,7 +91,7 @@ async def test_power_factory_without_unit_migration( async def test_block_rest_sensor( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block REST sensor.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "rssi") @@ -98,7 +100,7 @@ async def test_block_rest_sensor( assert hass.states.get(entity_id).state == "-64" monkeypatch.setitem(mock_block_device.status["wifi_sta"], "rssi", -71) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) assert hass.states.get(entity_id).state == "-71" @@ -304,7 +306,7 @@ async def test_rpc_sensor_error( async def test_rpc_polling_sensor( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC polling sensor.""" entity_id = register_entity(hass, SENSOR_DOMAIN, "test_name_rssi", "wifi-rssi") @@ -313,7 +315,7 @@ async def test_rpc_polling_sensor( assert hass.states.get(entity_id).state == "-63" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "wifi", "rssi", "-70") - await mock_polling_rpc_update(hass) + await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == "-70" diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 7a709e0cc2e..115ad5edabb 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -9,6 +9,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ICON, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -16,10 +17,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import init_integration RELAY_BLOCK_ID = 0 +GAS_VALVE_BLOCK_ID = 6 async def test_block_device_services(hass: HomeAssistant, mock_block_device) -> None: @@ -226,3 +229,51 @@ async def test_rpc_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_block_device_gas_valve( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test block device Shelly Gas with Valve addon.""" + registry = er.async_get(hass) + await init_integration(hass, 1, "SHGS-1") + entity_id = "switch.test_name_valve" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-valve_0-valve" + + assert hass.states.get(entity_id).state == STATE_OFF # valve is closed + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON # valve is open + assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF # valve is closed + assert state.attributes.get(ATTR_ICON) == "mdi:valve-closed" + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON # valve is open + assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index ed5dd81339e..1ff2ac99814 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.shelly.const import DOMAIN @@ -37,7 +38,7 @@ from tests.common import mock_restore_cache async def test_block_update( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device update entity.""" entity_registry = async_get(hass) @@ -75,7 +76,7 @@ async def test_block_update( assert state.attributes[ATTR_IN_PROGRESS] is True monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_firmware_update") assert state.state == STATE_OFF @@ -85,7 +86,7 @@ async def test_block_update( async def test_block_beta_update( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_block_device, monkeypatch ) -> None: """Test block device beta update entity.""" entity_registry = async_get(hass) @@ -108,7 +109,7 @@ async def test_block_beta_update( assert state.attributes[ATTR_IN_PROGRESS] is False monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "2b") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_ON @@ -131,7 +132,7 @@ async def test_block_beta_update( assert state.attributes[ATTR_IN_PROGRESS] is True monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2b") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_OFF @@ -389,7 +390,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( async def test_rpc_beta_update( - hass: HomeAssistant, mock_rpc_device, monkeypatch + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device, monkeypatch ) -> None: """Test RPC device beta update entity.""" entity_registry = async_get(hass) @@ -425,7 +426,7 @@ async def test_rpc_beta_update( "beta": {"version": "2b"}, }, ) - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_ON @@ -448,7 +449,7 @@ async def test_rpc_beta_update( assert state.attributes[ATTR_IN_PROGRESS] is True monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") - await mock_rest_update(hass) + await mock_rest_update(hass, freezer) state = hass.states.get("update.test_name_beta_firmware_update") assert state.state == STATE_OFF diff --git a/tests/components/shopping_list/test_config_flow.py b/tests/components/shopping_list/test_config_flow.py index 4f4ea3f2188..34d74d18046 100644 --- a/tests/components/shopping_list/test_config_flow.py +++ b/tests/components/shopping_list/test_config_flow.py @@ -1,8 +1,8 @@ """Test config flow.""" -from homeassistant import data_entry_flow from homeassistant.components.shopping_list.const import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType async def test_import(hass: HomeAssistant) -> None: @@ -11,7 +11,7 @@ async def test_import(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data={} ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY async def test_user(hass: HomeAssistant) -> None: @@ -21,7 +21,7 @@ async def test_user(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -32,5 +32,16 @@ async def test_user_confirm(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER}, data={} ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["result"].data == {} + + +async def test_onboarding_flow(hass: HomeAssistant) -> None: + """Test the onboarding configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "onboarding"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Shopping list" + assert result["data"] == {} diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index e3e80d80e52..a7a819d6ac9 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -37,7 +37,7 @@ from homeassistant.components.smartthings.const import ( STORAGE_VERSION, ) from homeassistant.config import async_process_ha_core_config -from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -55,13 +55,13 @@ COMPONENT_PREFIX = "homeassistant.components.smartthings." async def setup_platform(hass, platform: str, *, devices=None, scenes=None): """Set up the SmartThings platform and prerequisites.""" hass.config.components.add(DOMAIN) - config_entry = ConfigEntry( - 2, - DOMAIN, - "Test", - {CONF_INSTALLED_APP_ID: str(uuid4())}, - SOURCE_USER, + config_entry = MockConfigEntry( + version=2, + domain=DOMAIN, + title="Test", + data={CONF_INSTALLED_APP_ID: str(uuid4())}, ) + config_entry.add_to_hass(hass) broker = DeviceBroker( hass, config_entry, Mock(), Mock(), devices or [], scenes or [] ) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 5c44a5af2e9..168756b0dfe 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -187,6 +187,7 @@ async def test_entry_created_existing_app_new_oauth_client( smartthings_mock.apps.return_value = [app] smartthings_mock.generate_app_oauth.return_value = app_oauth_client smartthings_mock.locations.return_value = [location] + smartthings_mock.create_app = AsyncMock(return_value=(app, app_oauth_client)) request = Mock() request.installed_app_id = installed_app_id request.auth_token = token @@ -366,7 +367,7 @@ async def test_entry_created_with_cloudhook( "async_create_cloudhook", AsyncMock(return_value="http://cloud.test"), ) as mock_create_cloudhook: - await smartapp.setup_smartapp_endpoint(hass) + await smartapp.setup_smartapp_endpoint(hass, True) # Webhook confirmation shown result = await hass.config_entries.flow.async_init( @@ -377,7 +378,8 @@ async def test_entry_created_with_cloudhook( assert result["description_placeholders"][ "webhook_url" ] == smartapp.get_webhook_url(hass) - assert mock_create_cloudhook.call_count == 1 + # One is done by app fixture, one done by new config entry + assert mock_create_cloudhook.call_count == 2 # Advance to PAT screen result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index f08d1b54985..0630ffd8392 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -422,7 +422,7 @@ async def test_broker_regenerates_token(hass: HomeAssistant, config_entry) -> No broker.connect() assert stored_action - await stored_action(None) # pylint:disable=not-callable + await stored_action(None) assert token.refresh.call_count == 1 assert config_entry.data[CONF_REFRESH_TOKEN] == token.refresh_token diff --git a/tests/components/smhi/conftest.py b/tests/components/smhi/conftest.py index 6ededa6d975..c474bc50b51 100644 --- a/tests/components/smhi/conftest.py +++ b/tests/components/smhi/conftest.py @@ -1,10 +1,18 @@ """Provide common smhi fixtures.""" import pytest +from homeassistant.components.smhi.const import DOMAIN + from tests.common import load_fixture @pytest.fixture(scope="session") def api_response(): """Return an API response.""" - return load_fixture("smhi.json") + return load_fixture("smhi.json", DOMAIN) + + +@pytest.fixture(scope="session") +def api_response_lack_data(): + """Return an API response.""" + return load_fixture("smhi_short.json", DOMAIN) diff --git a/tests/components/smhi/fixtures/smhi.json b/tests/components/smhi/fixtures/smhi.json new file mode 100644 index 00000000000..35770ddd355 --- /dev/null +++ b/tests/components/smhi/fixtures/smhi.json @@ -0,0 +1,10084 @@ +{ + "approvedTime": "2023-08-07T07:07:34Z", + "referenceTime": "2023-08-07T07:00:00Z", + "geometry": { + "type": "Point", + "coordinates": [[15.990068, 57.997072]] + }, + "timeSeries": [ + { + "validTime": "2023-08-07T08:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [18.4] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [0.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [93] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [37] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [7] + } + ] + }, + { + "validTime": "2023-08-07T09:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [18.2] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [0.1] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [103] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [27] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.6] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [7] + } + ] + }, + { + "validTime": "2023-08-07T10:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.5] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [104] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [27] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.6] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T11:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.6] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [109] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.6] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T12:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.1] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [114] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [96] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T13:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.7] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [7.5] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [105] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [91] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [8.8] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T14:00:00Z", + "parameters": [ + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.2] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.5] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [10.7] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [99] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [86] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T15:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [16.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [9.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [108] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.4] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [89] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [8.8] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T16:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.4] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [16.5] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [11.5] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [113] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [84] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [10.1] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T17:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.4] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [16.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [9.5] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [100] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [88] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T18:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [990.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [15.6] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [7.7] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [107] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [91] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T19:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [990.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [15.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [7.3] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [88] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.2] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [94] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T20:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [989.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [15.0] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [4.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [39] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [95] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T21:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [989.5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [66] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.3] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [98] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T22:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [989.0] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.9] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.1] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [81] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.4] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [98] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-07T23:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [988.5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [15.0] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [81] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.2] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-08T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [987.5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [357] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [99] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.9] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-08T01:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [986.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [5] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.6] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [99] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T02:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [985.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [359] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.4] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T03:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [985.0] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.3] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [293] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [2] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T04:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [984.5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [295] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [0.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [4] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.7] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.3] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T05:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [984.0] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [0.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [221] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [5] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.4] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.7] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.3] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T06:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [983.5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.7] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [230] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [1.9] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [5] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T07:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [983.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.5] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [209] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.4] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [98] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [6] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.9] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T08:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [983.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [197] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [98] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [6] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T09:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [983.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [13.9] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [192] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [98] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [6] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T10:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [983.4] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [13.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.5] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [184] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [6] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.8] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T11:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [983.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [13.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [181] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [4] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [984.1] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.7] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [183] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [3] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.3] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T13:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [984.4] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.6] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [190] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [96] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [2] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T14:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [985.0] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [205] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.3] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [96] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [10.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-08T15:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [985.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.9] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [211] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [96] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [11.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.3] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T16:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [986.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.9] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [213] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [95] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [11.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.2] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T17:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [987.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [209] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [96] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [11.8] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.3] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T18:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [989.1] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [208] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.6] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [95] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [13.8] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.9] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.3] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T19:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [990.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [10.9] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [4.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [203] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [95] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [13.8] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.2] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T20:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [991.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [10.6] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [4.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [201] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [95] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T21:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [10.6] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [187] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [95] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.4] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.9] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.7] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.7] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T22:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [993.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [10.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.1] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [185] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [96] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [13.0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.9] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-08T23:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [994.5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [10.9] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [8.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [192] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [90] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [13.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.3] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-09T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [995.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [11.1] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [193] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [85] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [13.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.3] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T01:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [996.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.3] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [12.5] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [188] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [82] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [13.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T02:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [996.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [13.3] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [189] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [81] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T03:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [997.4] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [14.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [187] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.6] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [78] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [11.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T04:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [998.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [13.9] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [171] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [80] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [11.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T05:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [998.9] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [13.7] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [167] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [80] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [11.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T06:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [999.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [14.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [165] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.6] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [80] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.1] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T07:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [999.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.9] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [15.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [166] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.7] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [77] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T08:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1000.1] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.3] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [16.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [169] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.9] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [75] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-09T09:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1000.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.5] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [24.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [167] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [77] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-09T10:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1000.7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [18.7] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [164] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.9] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [89] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-09T11:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1001.0] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [8.5] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [159] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.9] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [94] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [12.1] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.3] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.4] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-09T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1001.4] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [3.1] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [166] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [95] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [13.4] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.5] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-09T18:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1006.2] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.0] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [199] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.2] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [99] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [5] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-10T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1007.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [10.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [1.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [200] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [99] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.8] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.5] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.7] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-10T06:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1009.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.0] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [182] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.3] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [19] + } + ] + }, + { + "validTime": "2023-08-10T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1011.1] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [13.9] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [30.9] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [174] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [75] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [8.1] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-10T18:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1011.9] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [43.1] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [143] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [89] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [2] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [18] + } + ] + }, + { + "validTime": "2023-08-11T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1012.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [11.7] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [169] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [98] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [4] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-11T06:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1013.5] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.3] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [214] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.2] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [4] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-11T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1015.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.6] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [27.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [197] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [69] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [4] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-11T18:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1015.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [16.1] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [35.3] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [156] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.3] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [82] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [1] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.7] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [8] + } + ] + }, + { + "validTime": "2023-08-12T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1015.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.3] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [2.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [191] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.4] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [97] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [1] + } + ] + }, + { + "validTime": "2023-08-12T06:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1014.8] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [12.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [40.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [171] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [92] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-12T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1014.0] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [17.0] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [50.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [225] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.4] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [82] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [7.8] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-13T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1013.9] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [13.6] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [31.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [233] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [92] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.6] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [2] + } + ] + }, + { + "validTime": "2023-08-13T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1013.6] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [20.0] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [46.8] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [234] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [4.1] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [59] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.9] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + } + ] + }, + { + "validTime": "2023-08-14T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1015.2] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [13.5] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [37.2] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [227] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.0] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [91] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.5] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.6] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + } + ] + }, + { + "validTime": "2023-08-14T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1015.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [20.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [49.0] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [216] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [56] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [4] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.4] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + } + ] + }, + { + "validTime": "2023-08-15T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1014.9] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [14.3] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [30.3] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [196] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [93] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.8] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [6] + } + ] + }, + { + "validTime": "2023-08-15T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1014.3] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [20.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [39.9] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [226] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [64] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.2] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.0] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.2] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + } + ] + }, + { + "validTime": "2023-08-16T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1014.9] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [13.8] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [31.6] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [228] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.8] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [93] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [3] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [0] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [5.9] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.8] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [2] + } + ] + }, + { + "validTime": "2023-08-16T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [1014.0] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [20.2] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [44.5] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [233] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [3.9] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [61] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [6] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [2] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [1] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [9.3] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [1.5] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [0] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [3] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.1] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [4] + } + ] + } + ] +} diff --git a/tests/components/smhi/fixtures/smhi_short.json b/tests/components/smhi/fixtures/smhi_short.json new file mode 100644 index 00000000000..ad9567b7f57 --- /dev/null +++ b/tests/components/smhi/fixtures/smhi_short.json @@ -0,0 +1,148 @@ +{ + "approvedTime": "2023-08-07T07:07:34Z", + "referenceTime": "2023-08-07T07:00:00Z", + "geometry": { + "type": "Point", + "coordinates": [[15.990068, 57.997072]] + }, + "timeSeries": [ + { + "validTime": "2023-08-07T08:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [-9] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [0] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [0.0] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [8] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [7] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [18.4] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [992.4] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [0.4] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [93] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [2.5] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [100] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [37] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [6.2] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [7] + } + ] + } + ] +} diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr new file mode 100644 index 00000000000..ade151ed128 --- /dev/null +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -0,0 +1,426 @@ +# serializer version: 1 +# name: test_forecast_daily + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }) +# --- +# name: test_forecast_daily.1 + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }) +# --- +# name: test_forecast_daily.2 + dict({ + 'cloud_coverage': 100, + 'condition': 'fog', + 'datetime': '2023-08-07T09:00:00', + 'humidity': 100, + 'precipitation': 0.0, + 'pressure': 992.0, + 'temperature': 18.0, + 'templow': 18.0, + 'wind_bearing': 103, + 'wind_gust_speed': 23.76, + 'wind_speed': 9.72, + }) +# --- +# name: test_forecast_daily.3 + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T15:00:00', + 'humidity': 89, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 16.0, + 'templow': 16.0, + 'wind_bearing': 108, + 'wind_gust_speed': 31.68, + 'wind_speed': 12.24, + }) +# --- +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + }) +# --- +# name: test_forecast_services + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }) +# --- +# name: test_forecast_services.1 + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }) +# --- +# name: test_forecast_services.2 + dict({ + 'cloud_coverage': 100, + 'condition': 'fog', + 'datetime': '2023-08-07T09:00:00', + 'humidity': 100, + 'precipitation': 0.0, + 'pressure': 992.0, + 'temperature': 18.0, + 'templow': 18.0, + 'wind_bearing': 103, + 'wind_gust_speed': 23.76, + 'wind_speed': 9.72, + }) +# --- +# name: test_forecast_services.3 + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T15:00:00', + 'humidity': 89, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 16.0, + 'templow': 16.0, + 'wind_bearing': 108, + 'wind_gust_speed': 31.68, + 'wind_speed': 12.24, + }) +# --- +# name: test_setup_hass + ReadOnlyDict({ + 'apparent_temperature': 18.0, + 'attribution': 'Swedish weather institute (SMHI)', + 'cloud_coverage': 100, + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-07T12:00:00', + 'humidity': 96, + 'precipitation': 0.0, + 'pressure': 991.0, + 'temperature': 18.0, + 'templow': 15.0, + 'wind_bearing': 114, + 'wind_gust_speed': 32.76, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-09T12:00:00', + 'humidity': 95, + 'precipitation': 6.3, + 'pressure': 1001.0, + 'temperature': 12.0, + 'templow': 11.0, + 'wind_bearing': 166, + 'wind_gust_speed': 48.24, + 'wind_speed': 18.0, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-10T12:00:00', + 'humidity': 75, + 'precipitation': 4.8, + 'pressure': 1011.0, + 'temperature': 14.0, + 'templow': 10.0, + 'wind_bearing': 174, + 'wind_gust_speed': 29.16, + 'wind_speed': 11.16, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-11T12:00:00', + 'humidity': 69, + 'precipitation': 0.6, + 'pressure': 1015.0, + 'temperature': 18.0, + 'templow': 12.0, + 'wind_bearing': 197, + 'wind_gust_speed': 27.36, + 'wind_speed': 10.08, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2023-08-12T12:00:00', + 'humidity': 82, + 'precipitation': 0.0, + 'pressure': 1014.0, + 'temperature': 17.0, + 'templow': 12.0, + 'wind_bearing': 225, + 'wind_gust_speed': 28.08, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-13T12:00:00', + 'humidity': 59, + 'precipitation': 0.0, + 'pressure': 1013.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 234, + 'wind_gust_speed': 35.64, + 'wind_speed': 14.76, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'partlycloudy', + 'datetime': '2023-08-14T12:00:00', + 'humidity': 56, + 'precipitation': 0.0, + 'pressure': 1015.0, + 'temperature': 21.0, + 'templow': 14.0, + 'wind_bearing': 216, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 88, + 'condition': 'partlycloudy', + 'datetime': '2023-08-15T12:00:00', + 'humidity': 64, + 'precipitation': 3.6, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 226, + 'wind_gust_speed': 33.12, + 'wind_speed': 13.68, + }), + dict({ + 'cloud_coverage': 75, + 'condition': 'partlycloudy', + 'datetime': '2023-08-16T12:00:00', + 'humidity': 61, + 'precipitation': 2.4, + 'pressure': 1014.0, + 'temperature': 20.0, + 'templow': 14.0, + 'wind_bearing': 233, + 'wind_gust_speed': 33.48, + 'wind_speed': 14.04, + }), + ]), + 'friendly_name': 'test', + 'humidity': 100, + 'precipitation_unit': , + 'pressure': 992.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 18.0, + 'temperature_unit': , + 'thunder_probability': 37, + 'visibility': 0.4, + 'visibility_unit': , + 'wind_bearing': 93, + 'wind_gust_speed': 22.32, + 'wind_speed': 9.0, + 'wind_speed_unit': , + }) +# --- +# name: test_setup_hass.1 + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2023-08-08T12:00:00', + 'humidity': 97, + 'precipitation': 10.6, + 'pressure': 984.0, + 'temperature': 15.0, + 'templow': 11.0, + 'wind_bearing': 183, + 'wind_gust_speed': 27.36, + 'wind_speed': 11.16, + }) +# --- diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 55b07530c39..67aa18ea75d 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -5,21 +5,13 @@ from unittest.mock import patch import pytest from smhi.smhi_lib import APIURL_TEMPLATE, SmhiForecast, SmhiForecastException +from syrupy.assertion import SnapshotAssertion from homeassistant.components.smhi.const import ATTR_SMHI_THUNDER_PROBABILITY from homeassistant.components.smhi.weather import CONDITION_CLASSES, RETRY_TIMEOUT from homeassistant.components.weather import ( ATTR_FORECAST, - ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRESSURE, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_GUST_SPEED, - ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, @@ -28,6 +20,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.components.weather.const import ( ATTR_WEATHER_CLOUD_COVERAGE, @@ -42,10 +35,14 @@ from . import ENTITY_ID, TEST_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator async def test_setup_hass( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + api_response: str, + snapshot: SnapshotAssertion, ) -> None: """Test for successfully setting up the smhi integration.""" uri = APIURL_TEMPLATE.format( @@ -58,37 +55,19 @@ async def test_setup_hass( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 # Testing the actual entity state for # deeper testing than normal unity test state = hass.states.get(ENTITY_ID) assert state - assert state.state == "sunny" - assert state.attributes[ATTR_WEATHER_CLOUD_COVERAGE] == 50 - assert state.attributes[ATTR_SMHI_THUNDER_PROBABILITY] == 33 - assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 16.92 - assert state.attributes[ATTR_ATTRIBUTION].find("SMHI") >= 0 - assert state.attributes[ATTR_WEATHER_HUMIDITY] == 55 - assert state.attributes[ATTR_WEATHER_PRESSURE] == 1024 - assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 17 - assert state.attributes[ATTR_WEATHER_VISIBILITY] == 50 - assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 6.84 - assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 134 - assert len(state.attributes["forecast"]) == 4 + assert state.state == "fog" + assert state.attributes == snapshot + assert len(state.attributes["forecast"]) == 10 forecast = state.attributes["forecast"][1] - assert forecast[ATTR_FORECAST_TIME] == "2018-09-02T12:00:00" - assert forecast[ATTR_FORECAST_TEMP] == 21 - assert forecast[ATTR_FORECAST_TEMP_LOW] == 6 - assert forecast[ATTR_FORECAST_PRECIPITATION] == 0 - assert forecast[ATTR_FORECAST_CONDITION] == "partlycloudy" - assert forecast[ATTR_FORECAST_PRESSURE] == 1026 - assert forecast[ATTR_FORECAST_WIND_BEARING] == 203 - assert forecast[ATTR_FORECAST_WIND_SPEED] == 6.12 - assert forecast[ATTR_FORECAST_WIND_GUST_SPEED] == 18.36 - assert forecast[ATTR_FORECAST_CLOUD_COVERAGE] == 100 + assert forecast == snapshot async def test_properties_no_data(hass: HomeAssistant) -> None: @@ -188,6 +167,9 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: with patch( "homeassistant.components.smhi.weather.Smhi.async_get_forecast", return_value=testdata, + ), patch( + "homeassistant.components.smhi.weather.Smhi.async_get_forecast_hour", + return_value=None, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -341,7 +323,7 @@ async def test_custom_speed_unit( assert state assert state.name == "test" - assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 16.92 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 22.32 entity_reg = er.async_get(hass) entity_reg.async_update_entity_options( @@ -353,4 +335,137 @@ async def test_custom_speed_unit( await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 4.7 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 6.2 + + +async def test_forecast_services( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, + api_response: str, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": ENTITY_ID, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert len(forecast1) == 10 + assert forecast1[0] == snapshot + assert forecast1[6] == snapshot + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "hourly", + "entity_id": ENTITY_ID, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert len(forecast1) == 72 + assert forecast1[0] == snapshot + assert forecast1[6] == snapshot + + +async def test_forecast_services_lack_of_data( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + aioclient_mock: AiohttpClientMocker, + api_response_lack_data: str, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast lacking data.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response_lack_data) + + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": ENTITY_ID, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 is None + + +async def test_forecast_service( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + api_response: str, + snapshot: SnapshotAssertion, +) -> None: + """Test forecast service.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": ENTITY_ID, "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == snapshot diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 5d9656b05d8..550040a9b25 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -1,16 +1,17 @@ """Tests for the SolarEdge coordinator services.""" from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.solaredge.const import ( CONF_SITE_ID, DEFAULT_NAME, DOMAIN, OVERVIEW_UPDATE_DELAY, - SENSOR_TYPES, ) +from homeassistant.components.solaredge.sensor import SENSOR_TYPES from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -20,7 +21,7 @@ API_KEY = "a1b2c3d4e5f6g7h8" @patch("homeassistant.components.solaredge.Solaredge") async def test_solaredgeoverviewdataservice_energy_values_validity( - mock_solaredge, hass: HomeAssistant + mock_solaredge, hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test overview energy data validity.""" mock_config_entry = MockConfigEntry( @@ -46,7 +47,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( } } mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") assert state @@ -55,7 +57,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( # Invalid energy values, lifeTimeData energy is lower than last year, month or day. mock_overview_data["overview"]["lifeTimeData"]["energy"] = 0 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") @@ -65,7 +68,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( # New valid energy values update mock_overview_data["overview"]["lifeTimeData"]["energy"] = 100001 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") @@ -75,7 +79,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( # Invalid energy values, lastYearData energy is lower than last month or day. mock_overview_data["overview"]["lastYearData"]["energy"] = 0 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_energy_this_year") @@ -92,7 +97,8 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( mock_overview_data["overview"]["lastMonthData"]["energy"] = 0.0 mock_overview_data["overview"]["lastDayData"]["energy"] = 0.0 mock_solaredge().get_overview.return_value = mock_overview_data - async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) + freezer.tick(OVERVIEW_UPDATE_DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.solaredge_lifetime_energy") diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index d4072055407..a3f74127283 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,14 +1,8 @@ """Tests for the Sonos config flow.""" import asyncio import logging -import sys from unittest.mock import Mock, patch -if sys.version_info[:2] < (3, 11): - from async_timeout import timeout as asyncio_timeout -else: - from asyncio import timeout as asyncio_timeout - import pytest from homeassistant import config_entries, data_entry_flow @@ -377,7 +371,7 @@ async def test_async_poll_manual_hosts_6( caplog.clear() # The discovery events should not fire, wait with a timeout. with pytest.raises(asyncio.TimeoutError): - async with asyncio_timeout(1.0): + async with asyncio.timeout(1.0): await speaker_1_activity.event.wait() await hass.async_block_till_done() assert "Activity on Living Room" not in caplog.text diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index d2f81ac18dc..ac892eeb2d8 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -9,8 +9,6 @@ from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, UnitOfTemp from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import mock_coro - CONFIG = { DOMAIN: { "space": "Home", @@ -83,7 +81,7 @@ SENSOR_OUTPUT = { @pytest.fixture def mock_client(hass, hass_client): """Start the Home Assistant HTTP component.""" - with patch("homeassistant.components.spaceapi", return_value=mock_coro(True)): + with patch("homeassistant.components.spaceapi", return_value=True): hass.loop.run_until_complete(async_setup_component(hass, "spaceapi", CONFIG)) hass.states.async_set( diff --git a/tests/components/spc/test_init.py b/tests/components/spc/test_init.py index 7e4faa68e00..1972b7af5c8 100644 --- a/tests/components/spc/test_init.py +++ b/tests/components/spc/test_init.py @@ -6,8 +6,6 @@ from homeassistant.components.spc import DATA_API from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED from homeassistant.core import HomeAssistant -from tests.common import mock_coro - async def test_valid_device_config(hass: HomeAssistant, monkeypatch) -> None: """Test valid device config.""" @@ -15,7 +13,7 @@ async def test_valid_device_config(hass: HomeAssistant, monkeypatch) -> None: with patch( "homeassistant.components.spc.SpcWebGateway.async_load_parameters", - return_value=mock_coro(True), + return_value=True, ): assert await async_setup_component(hass, "spc", config) is True @@ -26,7 +24,7 @@ async def test_invalid_device_config(hass: HomeAssistant, monkeypatch) -> None: with patch( "homeassistant.components.spc.SpcWebGateway.async_load_parameters", - return_value=mock_coro(True), + return_value=True, ): assert await async_setup_component(hass, "spc", config) is False @@ -53,7 +51,7 @@ async def test_update_alarm_device(hass: HomeAssistant) -> None: mock_areas.return_value = {"1": area_mock} with patch( "homeassistant.components.spc.SpcWebGateway.async_load_parameters", - return_value=mock_coro(True), + return_value=True, ): assert await async_setup_component(hass, "spc", config) is True diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 53356a85c4e..6a629f9603d 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -20,7 +20,10 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE +from homeassistant.helpers.trigger_template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, +) from tests.common import MockConfigEntry diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 3310e9ce9cd..1ae213e4bf1 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -1,4 +1,9 @@ """Tests for the srp_energy sensor platform.""" +import time +from unittest.mock import patch + +from requests.models import HTTPError + from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -10,6 +15,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: """Test the srp energy sensors.""" @@ -37,3 +44,47 @@ async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: assert usage_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert usage_state.attributes.get(ATTR_ICON) == "mdi:flash" + + +async def test_srp_entity_update_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the SrpEntity.""" + + with patch( + "homeassistant.components.srp_energy.SrpEnergyClient", autospec=True + ) as srp_energy_mock: + client = srp_energy_mock.return_value + client.validate.return_value = True + client.usage.side_effect = HTTPError + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + usage_state = hass.states.get("sensor.home_energy_usage") + assert usage_state is None + + +async def test_srp_entity_timeout( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the SrpEntity timing out.""" + + with patch( + "homeassistant.components.srp_energy.SrpEnergyClient", autospec=True + ) as srp_energy_mock, patch( + "homeassistant.components.srp_energy.coordinator.TIMEOUT", 0 + ): + client = srp_energy_mock.return_value + client.validate.return_value = True + client.usage = lambda _, __, ___: time.sleep(1) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + usage_state = hass.states.get("sensor.home_energy_usage") + assert usage_state is None diff --git a/tests/components/starlink/fixtures/location_data_success.json b/tests/components/starlink/fixtures/location_data_success.json new file mode 100644 index 00000000000..4d18d22d12e --- /dev/null +++ b/tests/components/starlink/fixtures/location_data_success.json @@ -0,0 +1,5 @@ +{ + "latitude": 37.422, + "longitude": -122.084, + "altitude": 100 +} diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py index dfc0d2415df..d83451ecc17 100644 --- a/tests/components/starlink/patchers.py +++ b/tests/components/starlink/patchers.py @@ -8,11 +8,16 @@ SETUP_ENTRY_PATCHER = patch( "homeassistant.components.starlink.async_setup_entry", return_value=True ) -COORDINATOR_SUCCESS_PATCHER = patch( +STATUS_DATA_SUCCESS_PATCHER = patch( "homeassistant.components.starlink.coordinator.status_data", return_value=json.loads(load_fixture("status_data_success.json", "starlink")), ) +LOCATION_DATA_SUCCESS_PATCHER = patch( + "homeassistant.components.starlink.coordinator.location_data", + return_value=json.loads(load_fixture("location_data_success.json", "starlink")), +) + DEVICE_FOUND_PATCHER = patch( "homeassistant.components.starlink.config_flow.get_id", return_value="some-valid-id" ) diff --git a/tests/components/starlink/snapshots/test_diagnostics.ambr b/tests/components/starlink/snapshots/test_diagnostics.ambr index 6f859aaf50d..3bb7f235017 100644 --- a/tests/components/starlink/snapshots/test_diagnostics.ambr +++ b/tests/components/starlink/snapshots/test_diagnostics.ambr @@ -16,6 +16,11 @@ 'alert_thermal_throttle': False, 'alert_unexpected_location': False, }), + 'location': dict({ + 'altitude': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), 'obstruction': dict({ 'raw_wedges_fraction_obstructed[]': list([ None, diff --git a/tests/components/starlink/test_diagnostics.py b/tests/components/starlink/test_diagnostics.py index 4bf8a619c88..231b58a2d5e 100644 --- a/tests/components/starlink/test_diagnostics.py +++ b/tests/components/starlink/test_diagnostics.py @@ -5,7 +5,7 @@ from homeassistant.components.starlink.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from .patchers import COORDINATOR_SUCCESS_PATCHER +from .patchers import LOCATION_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -23,7 +23,7 @@ async def test_diagnostics( data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, ) - with COORDINATOR_SUCCESS_PATCHER: + with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 72d3be52b4a..94a8a2a341b 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from .patchers import COORDINATOR_SUCCESS_PATCHER +from .patchers import LOCATION_DATA_SUCCESS_PATCHER, STATUS_DATA_SUCCESS_PATCHER from tests.common import MockConfigEntry @@ -16,7 +16,7 @@ async def test_successful_entry(hass: HomeAssistant) -> None: data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, ) - with COORDINATOR_SUCCESS_PATCHER: + with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -33,7 +33,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, ) - with COORDINATOR_SUCCESS_PATCHER: + with STATUS_DATA_SUCCESS_PATCHER, LOCATION_DATA_SUCCESS_PATCHER: entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py index 3907427bbd3..7b691410907 100644 --- a/tests/components/startca/test_sensor.py +++ b/tests/components/startca/test_sensor.py @@ -157,18 +157,15 @@ async def test_unlimited_setup( await async_setup_component(hass, "sensor", {"sensor": config}) await hass.async_block_till_done() - state = hass.states.get("sensor.start_ca_usage_ratio") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.state == "0" + # These sensors should not be created for unlimited setups + assert hass.states.get("sensor.start_ca_usage_ratio") is None + assert hass.states.get("sensor.start_ca_data_limit") is None + assert hass.states.get("sensor.start_ca_remaining") is None state = hass.states.get("sensor.start_ca_usage") assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES assert state.state == "0.0" - state = hass.states.get("sensor.start_ca_data_limit") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES - assert state.state == "inf" - state = hass.states.get("sensor.start_ca_used_download") assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES assert state.state == "0.0" @@ -201,10 +198,6 @@ async def test_unlimited_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES assert state.state == "6.48" - state = hass.states.get("sensor.start_ca_remaining") - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.GIGABYTES - assert state.state == "inf" - async def test_bad_return_code( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 4b77e2d0725..780e550f224 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import patch from freezegun import freeze_time +import pytest from homeassistant import config as hass_config from homeassistant.components.recorder import Recorder @@ -1286,12 +1287,14 @@ async def test_initialize_from_database( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS +@pytest.mark.freeze_time( + datetime(dt_util.utcnow().year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) +) async def test_initialize_from_database_with_maxage( recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test initializing the statistics from the database.""" - now = dt_util.utcnow() - current_time = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) + current_time = dt_util.utcnow() # Testing correct retrieval from recorder, thus we do not # want purging to occur within the class itself. diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index c75525c6061..7ea583c0ec3 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -1,5 +1,4 @@ """Collection of test helpers.""" -from datetime import datetime from fractions import Fraction import functools from functools import partial @@ -15,8 +14,9 @@ from homeassistant.components.stream.fmp4utils import ( XYW_ROW, find_box, ) +from homeassistant.util import dt as dt_util -FAKE_TIME = datetime.utcnow() +FAKE_TIME = dt_util.utcnow() # Segment with defaults filled in for use in tests DefaultSegment = partial( diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 17918ff93df..cd13ab340c2 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -7,6 +7,7 @@ import math import re from urllib.parse import urlparse +from aiohttp import web from dateutil import parser import pytest @@ -394,6 +395,9 @@ async def test_ll_hls_playlist_bad_msn_part( ) -> None: """Test some playlist requests with invalid _HLS_msn/_HLS_part.""" + async def _handler_bad_request(request): + raise web.HTTPBadRequest() + await async_setup_component( hass, "stream", @@ -413,6 +417,12 @@ async def test_ll_hls_playlist_bad_msn_part( hls_client = await hls_stream(stream) + # All GET calls to '/.../playlist.m3u8' should raise a HTTPBadRequest exception + hls_client.http_client.app.router._frozen = False + parsed_url = urlparse(stream.endpoint_url(HLS_PROVIDER)) + url = "/".join(parsed_url.path.split("/")[:-1]) + "/playlist.m3u8" + hls_client.http_client.app.router.add_route("GET", url, _handler_bad_request) + # If the Playlist URI contains an _HLS_part directive but no _HLS_msn # directive, the Server MUST return Bad Request, such as HTTP 400. diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index e0152190d90..bd998b008be 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -643,7 +643,7 @@ async def test_pts_out_of_order(hass: HomeAssistant) -> None: async def test_stream_stopped_while_decoding(hass: HomeAssistant) -> None: - """Tests that worker quits when stop() is called while decodign.""" + """Tests that worker quits when stop() is called while decoding.""" # Add some synchronization so that the test can pause the background # worker. When the worker is stopped, the test invokes stop() which # will cause the worker thread to exit once it enters the decode @@ -966,7 +966,7 @@ async def test_h265_video_is_hvc1(hass: HomeAssistant, worker_finished_stream) - async def test_get_image(hass: HomeAssistant, h264_video, filename) -> None: - """Test that the has_keyframe metadata matches the media.""" + """Test getting an image from the stream.""" await async_setup_component(hass, "stream", {"stream": {}}) # Since libjpeg-turbo is not installed on the CI runner, we use a mock @@ -976,10 +976,30 @@ async def test_get_image(hass: HomeAssistant, h264_video, filename) -> None: mock_turbo_jpeg_singleton.instance.return_value = mock_turbo_jpeg() stream = create_stream(hass, h264_video, {}, dynamic_stream_settings()) - with patch.object(hass.config, "is_allowed_path", return_value=True): + worker_wake = threading.Event() + + temp_av_open = av.open + + def blocking_open(stream_source, *args, **kwargs): + # Block worker thread until test wakes up + worker_wake.wait() + return temp_av_open(stream_source, *args, **kwargs) + + with patch.object(hass.config, "is_allowed_path", return_value=True), patch( + "av.open", new=blocking_open + ): make_recording = hass.async_create_task(stream.async_record(filename)) + assert stream._keyframe_converter._image is None + # async_get_image should not work because there is no keyframe yet + assert not await stream.async_get_image() + # async_get_image should work if called with wait_for_next_keyframe=True + next_keyframe_request = hass.async_create_task( + stream.async_get_image(wait_for_next_keyframe=True) + ) + worker_wake.set() await make_recording - assert stream._keyframe_converter._image is None + + assert await next_keyframe_request == EMPTY_8_6_JPEG assert await stream.async_get_image() == EMPTY_8_6_JPEG @@ -1008,7 +1028,7 @@ async def test_worker_disable_ll_hls(hass: HomeAssistant) -> None: async def test_get_image_rotated(hass: HomeAssistant, h264_video, filename) -> None: - """Test that the has_keyframe metadata matches the media.""" + """Test getting a rotated image.""" await async_setup_component(hass, "stream", {"stream": {}}) # Since libjpeg-turbo is not installed on the CI runner, we use a mock diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index e2fdf9ae508..52c57e7348a 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -1,6 +1,6 @@ """Sample API response data for tests.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.components.subaru.const import ( API_GEN_1, @@ -58,7 +58,7 @@ VEHICLE_DATA = { }, } -MOCK_DATETIME = datetime.fromtimestamp(1595560000, timezone.utc) +MOCK_DATETIME = datetime.fromtimestamp(1595560000, UTC) VEHICLE_STATUS_EV = { VEHICLE_STATUS: { diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index fc959fc434d..c3df10ed618 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -264,6 +264,7 @@ async def test_pin_form_init(pin_form) -> None: "step_id": "pin", "type": "form", "last_step": None, + "preview": None, } assert pin_form == expected diff --git a/tests/components/subaru/test_device_tracker.py b/tests/components/subaru/test_device_tracker.py new file mode 100644 index 00000000000..616d868016e --- /dev/null +++ b/tests/components/subaru/test_device_tracker.py @@ -0,0 +1,56 @@ +"""Test Subaru device tracker.""" +from copy import deepcopy +from unittest.mock import patch + +from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP, VEHICLE_STATUS + +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .api_responses import EXPECTED_STATE_EV_IMPERIAL, VEHICLE_STATUS_EV +from .conftest import MOCK_API_FETCH, MOCK_API_GET_DATA, advance_time_to_next_fetch + +DEVICE_ID = "device_tracker.test_vehicle_2" + + +async def test_device_tracker(hass: HomeAssistant, ev_entry) -> None: + """Test subaru device tracker entity exists and has correct info.""" + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(DEVICE_ID) + assert entry + actual = hass.states.get(DEVICE_ID) + assert ( + actual.attributes.get(ATTR_LONGITUDE) == EXPECTED_STATE_EV_IMPERIAL[LONGITUDE] + ) + assert actual.attributes.get(ATTR_LATITUDE) == EXPECTED_STATE_EV_IMPERIAL[LATITUDE] + + +async def test_device_tracker_none_data(hass: HomeAssistant, ev_entry) -> None: + """Test when location information contains None.""" + bad_status = deepcopy(VEHICLE_STATUS_EV) + bad_status[VEHICLE_STATUS][LATITUDE] = None + bad_status[VEHICLE_STATUS][LONGITUDE] = None + bad_status[VEHICLE_STATUS][TIMESTAMP] = None + with patch(MOCK_API_FETCH), patch(MOCK_API_GET_DATA, return_value=bad_status): + advance_time_to_next_fetch(hass) + await hass.async_block_till_done() + + actual = hass.states.get(DEVICE_ID) + assert not actual.attributes.get(ATTR_LATITUDE) + assert not actual.attributes.get(ATTR_LONGITUDE) + + +async def test_device_tracker_missing_data(hass: HomeAssistant, ev_entry) -> None: + """Test when location keys are missing from vehicle status.""" + bad_status = deepcopy(VEHICLE_STATUS_EV) + bad_status[VEHICLE_STATUS].pop(LATITUDE) + bad_status[VEHICLE_STATUS].pop(LONGITUDE) + bad_status[VEHICLE_STATUS].pop(TIMESTAMP) + with patch(MOCK_API_FETCH), patch(MOCK_API_GET_DATA, return_value=bad_status): + advance_time_to_next_fetch(hass) + await hass.async_block_till_done() + + actual = hass.states.get(DEVICE_ID) + assert not actual.attributes.get(ATTR_LATITUDE) + assert not actual.attributes.get(ATTR_LONGITUDE) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index fac744d0c0e..a0c0bfca825 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -143,6 +143,7 @@ async def test_device_registry_config_entry_1( entity_registry = er.async_get(hass) switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=switch_config_entry.entry_id, @@ -170,7 +171,6 @@ async def test_device_registry_config_entry_1( }, title="ABC", ) - switch_as_x_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) @@ -202,6 +202,7 @@ async def test_device_registry_config_entry_2( entity_registry = er.async_get(hass) switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=switch_config_entry.entry_id, @@ -313,6 +314,7 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: entity_registry = er.async_get(hass) test_config_entry = MockConfigEntry() + test_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=test_config_entry.entry_id, @@ -504,6 +506,7 @@ async def test_entity_name( device_registry = dr.async_get(hass) switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=switch_config_entry.entry_id, @@ -559,6 +562,7 @@ async def test_custom_name_1( device_registry = dr.async_get(hass) switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=switch_config_entry.entry_id, @@ -622,6 +626,7 @@ async def test_custom_name_2( device_registry = dr.async_get(hass) switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=switch_config_entry.entry_id, diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 6b592b25077..f35ff9fbbf2 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -77,7 +77,7 @@ async def test_update_fail( state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE - entity_id = f"sensor.{slugify(device.name)}_power_consumption" + entity_id = f"sensor.{slugify(device.name)}_power" state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE @@ -92,7 +92,7 @@ async def test_update_fail( state = hass.states.get(entity_id) assert state.state != STATE_UNAVAILABLE - entity_id = f"sensor.{slugify(device.name)}_power_consumption" + entity_id = f"sensor.{slugify(device.name)}_power" state = hass.states.get(entity_id) assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py index 41f409062ce..03073b21a96 100644 --- a/tests/components/switcher_kis/test_sensor.py +++ b/tests/components/switcher_kis/test_sensor.py @@ -13,16 +13,16 @@ DEVICE_SENSORS_TUPLE = ( ( DUMMY_PLUG_DEVICE, [ - "power_consumption", - "electric_current", + ("power", "power_consumption"), + ("current", "electric_current"), ], ), ( DUMMY_WATER_HEATER_DEVICE, [ - "power_consumption", - "electric_current", - "remaining_time", + ("power", "power_consumption"), + ("current", "electric_current"), + ("remaining_time", "remaining_time"), ], ), ) @@ -39,10 +39,10 @@ async def test_sensor_platform(hass: HomeAssistant, mock_bridge) -> None: assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 for device, sensors in DEVICE_SENSORS_TUPLE: - for sensor in sensors: + for sensor, field in sensors: entity_id = f"sensor.{slugify(device.name)}_{sensor}" state = hass.states.get(entity_id) - assert state.state == str(getattr(device, sensor)) + assert state.state == str(getattr(device, field)) async def test_sensor_disabled(hass: HomeAssistant, mock_bridge) -> None: @@ -80,13 +80,13 @@ async def test_sensor_update(hass: HomeAssistant, mock_bridge, monkeypatch) -> N assert mock_bridge device = DUMMY_WATER_HEATER_DEVICE - sensor = "power_consumption" - entity_id = f"sensor.{slugify(device.name)}_{sensor}" + field = "power_consumption" + entity_id = f"sensor.{slugify(device.name)}_power" state = hass.states.get(entity_id) - assert state.state == str(getattr(device, sensor)) + assert state.state == str(getattr(device, field)) - monkeypatch.setattr(device, sensor, 1431) + monkeypatch.setattr(device, field, 1431) mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) await hass.async_block_till_done() diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index ae6172af6d8..948e55649fc 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_USER_INPUT = { @@ -90,7 +90,7 @@ async def test_syncthru_not_supported(hass: HomeAssistant) -> None: async def test_unknown_state(hass: HomeAssistant) -> None: """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", return_value=mock_coro()), patch.object( + with patch.object(SyncThru, "update"), patch.object( SyncThru, "is_unknown_state", return_value=True ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index f0fef1dff5a..dcbb33b587e 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -2,18 +2,24 @@ from http import HTTPStatus from unittest.mock import MagicMock, patch +import pytest import requests from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.components.tado.const import DOMAIN +from homeassistant.components.tado.const import ( + CONF_FALLBACK, + CONST_OVERLAY_TADO_DEFAULT, + 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 -def _get_mock_tado_api(getMe=None): +def _get_mock_tado_api(getMe=None) -> MagicMock: mock_tado = MagicMock() if isinstance(getMe, Exception): type(mock_tado).getMe = MagicMock(side_effect=getMe) @@ -22,13 +28,100 @@ def _get_mock_tado_api(getMe=None): return mock_tado -async def test_form(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "error"), + [ + (KeyError, "invalid_auth"), + (RuntimeError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle Form Exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Test a retry to recover, upon failure + mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", + return_value=mock_tado_api, + ), patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "myhome" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={"username": "test-username"}) + 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( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_FALLBACK: CONST_OVERLAY_TADO_DEFAULT} + + +async def test_create_entry(hass: HomeAssistant) -> None: """Test we can setup though the user path.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) @@ -40,15 +133,15 @@ async def test_form(hass: HomeAssistant) -> None: "homeassistant.components.tado.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["title"] == "myhome" - assert result2["data"] == { + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "myhome" + assert result["data"] == { "username": "test-username", "password": "test-password", } @@ -69,13 +162,13 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -92,13 +185,13 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} async def test_no_homes(hass: HomeAssistant) -> None: @@ -113,13 +206,13 @@ async def test_no_homes(hass: HomeAssistant) -> None: "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "test-username", "password": "test-password"}, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "no_homes"} + assert result["type"] == "form" + assert result["errors"] == {"base": "no_homes"} async def test_form_homekit(hass: HomeAssistant) -> None: diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 6a82a0f0e73..2bfb4a9d5e2 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -125,13 +125,13 @@ async def test_controlling_state_via_mqtt_switchname( ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_UNKNOWN assert not state.attributes.get(ATTR_ASSUMED_STATE) @@ -139,35 +139,35 @@ async def test_controlling_state_via_mqtt_switchname( async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", '{"Custom Name":{"Action":"ON"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_ON async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", '{"Custom Name":{"Action":"OFF"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_OFF # Test periodic state update async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", '{"Custom Name":"ON"}') - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", '{"Custom Name":"OFF"}') - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_OFF # Test polled state update async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", '{"StatusSNS":{"Custom Name":"ON"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_ON async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", '{"StatusSNS":{"Custom Name":"OFF"}}' ) - state = hass.states.get("binary_sensor.custom_name") + state = hass.states.get("binary_sensor.tasmota_custom_name") assert state.state == STATE_OFF @@ -243,9 +243,9 @@ async def test_friendly_names( assert state.state == "unavailable" assert state.attributes.get("friendly_name") == "Tasmota binary_sensor 1" - state = hass.states.get("binary_sensor.beer") + state = hass.states.get("binary_sensor.tasmota_beer") assert state.state == "unavailable" - assert state.attributes.get("friendly_name") == "Beer" + assert state.attributes.get("friendly_name") == "Tasmota Beer" async def test_off_delay( diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 703dd2a1893..a184f650fae 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -129,7 +129,7 @@ async def help_test_availability_when_connection_lost( domain, config, sensor_config=None, - entity_id="test", + object_id="tasmota_test", ): """Test availability after MQTT disconnection. @@ -156,7 +156,7 @@ async def help_test_availability_when_connection_lost( config_get_state_online(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE # Disconnected from MQTT server -> state changed to unavailable @@ -165,7 +165,7 @@ async def help_test_availability_when_connection_lost( await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Reconnected to MQTT server -> state still unavailable @@ -174,7 +174,7 @@ async def help_test_availability_when_connection_lost( await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Receive LWT again @@ -184,7 +184,7 @@ async def help_test_availability_when_connection_lost( config_get_state_online(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE @@ -194,7 +194,7 @@ async def help_test_availability( domain, config, sensor_config=None, - entity_id="test", + object_id="tasmota_test", ): """Test availability. @@ -214,7 +214,7 @@ async def help_test_availability( ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message( @@ -223,7 +223,7 @@ async def help_test_availability( config_get_state_online(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message( @@ -232,7 +232,7 @@ async def help_test_availability( config_get_state_offline(config), ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE @@ -242,7 +242,7 @@ async def help_test_availability_discovery_update( domain, config, sensor_config=None, - entity_id="test", + object_id="tasmota_test", ): """Test update of discovered TasmotaAvailability. @@ -280,17 +280,17 @@ async def help_test_availability_discovery_update( ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, online1) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, offline1) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Change availability settings @@ -302,13 +302,13 @@ async def help_test_availability_discovery_update( async_fire_mqtt_message(hass, availability_topic1, online2) async_fire_mqtt_message(hass, availability_topic2, online1) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE # Verify we are subscribing to the new topic async_fire_mqtt_message(hass, availability_topic2, online2) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE @@ -390,8 +390,8 @@ async def help_test_discovery_removal( config2, sensor_config1=None, sensor_config2=None, - entity_id="test", - name="Test", + object_id="tasmota_test", + name="Tasmota Test", ): """Test removal of discovered entity.""" device_reg = dr.async_get(hass) @@ -416,11 +416,11 @@ async def help_test_discovery_removal( connections={(dr.CONNECTION_NETWORK_MAC, config1[CONF_MAC])} ) assert device_entry is not None - entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") + entity_entry = entity_reg.async_get(f"{domain}.{object_id}") assert entity_entry is not None # Verify state is added - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is not None assert state.name == name @@ -439,11 +439,11 @@ async def help_test_discovery_removal( connections={(dr.CONNECTION_NETWORK_MAC, config2[CONF_MAC])} ) assert device_entry is not None - entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") + entity_entry = entity_reg.async_get(f"{domain}.{object_id}") assert entity_entry is None # Verify state is removed - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is None @@ -455,8 +455,8 @@ async def help_test_discovery_update_unchanged( config, discovery_update, sensor_config=None, - entity_id="test", - name="Test", + object_id="tasmota_test", + name="Tasmota Test", ): """Test update of discovered component with and without changes. @@ -479,7 +479,7 @@ async def help_test_discovery_update_unchanged( ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is not None assert state.name == name @@ -538,7 +538,13 @@ async def help_test_discovery_device_remove( async def help_test_entity_id_update_subscriptions( - hass, mqtt_mock, domain, config, topics=None, sensor_config=None, entity_id="test" + hass, + mqtt_mock, + domain, + config, + topics=None, + sensor_config=None, + object_id="tasmota_test", ): """Test MQTT subscriptions are managed when entity_id is updated.""" entity_reg = er.async_get(hass) @@ -562,7 +568,7 @@ async def help_test_entity_id_update_subscriptions( topics = [get_topic_tele_state(config), get_topic_tele_will(config)] assert len(topics) > 0 - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is not None assert mqtt_mock.async_subscribe.call_count == len(topics) for topic in topics: @@ -570,11 +576,11 @@ async def help_test_entity_id_update_subscriptions( mqtt_mock.async_subscribe.reset_mock() entity_reg.async_update_entity( - f"{domain}.{entity_id}", new_entity_id=f"{domain}.milk" + f"{domain}.{object_id}", new_entity_id=f"{domain}.milk" ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state is None state = hass.states.get(f"{domain}.milk") @@ -584,7 +590,7 @@ async def help_test_entity_id_update_subscriptions( async def help_test_entity_id_update_discovery_update( - hass, mqtt_mock, domain, config, sensor_config=None, entity_id="test" + hass, mqtt_mock, domain, config, sensor_config=None, object_id="tasmota_test" ): """Test MQTT discovery update after entity_id is updated.""" entity_reg = er.async_get(hass) @@ -606,16 +612,16 @@ async def help_test_entity_id_update_discovery_update( async_fire_mqtt_message(hass, topic, config_get_state_online(config)) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, topic, config_get_state_offline(config)) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.{entity_id}") + state = hass.states.get(f"{domain}.{object_id}") assert state.state == STATE_UNAVAILABLE entity_reg.async_update_entity( - f"{domain}.{entity_id}", new_entity_id=f"{domain}.milk" + f"{domain}.{object_id}", new_entity_id=f"{domain}.milk" ) await hass.async_block_till_done() assert hass.states.get(f"{domain}.milk") diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index 156ea365b48..e2bdc8b2ca7 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -55,6 +55,7 @@ async def test_missing_relay( @pytest.mark.parametrize( ("relay_config", "num_covers"), [ + ([3, 3, 3, 3, 3, 3, 1, 1, 3, 3] + [3, 3] * 12, 16), ([3, 3, 3, 3, 3, 3, 1, 1, 3, 3], 4), ([3, 3, 3, 3, 0, 0, 0, 0], 2), ([3, 3, 1, 1, 0, 0, 0, 0], 1), @@ -658,7 +659,7 @@ async def test_availability_when_connection_lost( mqtt_mock, Platform.COVER, config, - entity_id="test_cover_1", + object_id="test_cover_1", ) @@ -671,7 +672,7 @@ async def test_availability( config["rl"][0] = 3 config["rl"][1] = 3 await help_test_availability( - hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" ) @@ -684,7 +685,7 @@ async def test_availability_discovery_update( config["rl"][0] = 3 config["rl"][1] = 3 await help_test_availability_discovery_update( - hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" ) @@ -727,7 +728,7 @@ async def test_discovery_removal_cover( Platform.COVER, config1, config2, - entity_id="test_cover_1", + object_id="test_cover_1", name="Test cover 1", ) @@ -753,7 +754,7 @@ async def test_discovery_update_unchanged_cover( Platform.COVER, config, discovery_update, - entity_id="test_cover_1", + object_id="test_cover_1", name="Test cover 1", ) @@ -787,7 +788,7 @@ async def test_entity_id_update_subscriptions( get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, Platform.COVER, config, topics, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, topics, object_id="test_cover_1" ) @@ -800,5 +801,5 @@ async def test_entity_id_update_discovery_update( config["rl"][0] = 3 config["rl"][1] = 3 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, Platform.COVER, config, entity_id="test_cover_1" + hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1" ) diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index ffff4b1b8b0..190c56b33f6 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -18,10 +18,7 @@ from homeassistant.setup import async_setup_component from .test_common import DEFAULT_CONFIG, remove_device -from tests.common import ( - async_fire_mqtt_message, - async_get_device_automations, -) +from tests.common import async_fire_mqtt_message, async_get_device_automations from tests.typing import MqttMockHAClient, WebSocketGenerator diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 9a3f4f91ec7..4fd9f293498 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -143,12 +143,12 @@ async def test_correct_config_discovery( connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None - entity_entry = entity_reg.async_get("switch.test") + entity_entry = entity_reg.async_get("switch.tasmota_test") assert entity_entry is not None - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state is not None - assert state.name == "Test" + assert state.name == "Tasmota Test" assert (mac, "switch", "relay", 0) in hass.data[ALREADY_DISCOVERED] @@ -530,11 +530,11 @@ async def test_entity_duplicate_discovery( ) await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") state_duplicate = hass.states.get("binary_sensor.beer1") assert state is not None - assert state.name == "Test" + assert state.name == "Tasmota Test" assert state_duplicate is None assert ( f"Entity already added, sending update: switch ('{mac}', 'switch', 'relay', 0)" diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 0b99036518e..2a50e2d43b5 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -226,10 +226,9 @@ async def test_availability_when_connection_lost( ) -> None: """Test availability after MQTT disconnection.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 await help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config + hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota" ) @@ -238,9 +237,10 @@ async def test_availability( ) -> None: """Test availability.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 - await help_test_availability(hass, mqtt_mock, Platform.FAN, config) + await help_test_availability( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) async def test_availability_discovery_update( @@ -248,9 +248,10 @@ async def test_availability_discovery_update( ) -> None: """Test availability discovery update.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 - await help_test_availability_discovery_update(hass, mqtt_mock, Platform.FAN, config) + await help_test_availability_discovery_update( + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" + ) async def test_availability_poll_state( @@ -276,14 +277,19 @@ async def test_discovery_removal_fan( ) -> None: """Test removal of discovered fan.""" config1 = copy.deepcopy(DEFAULT_CONFIG) - config1["dn"] = "Test" config1["if"] = 1 config2 = copy.deepcopy(DEFAULT_CONFIG) - config2["dn"] = "Test" config2["if"] = 0 await help_test_discovery_removal( - hass, mqtt_mock, caplog, Platform.FAN, config1, config2 + hass, + mqtt_mock, + caplog, + Platform.FAN, + config1, + config2, + object_id="tasmota", + name="Tasmota", ) @@ -295,13 +301,19 @@ async def test_discovery_update_unchanged_fan( ) -> None: """Test update of discovered fan.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 with patch( "homeassistant.components.tasmota.fan.TasmotaFan.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, Platform.FAN, config, discovery_update + hass, + mqtt_mock, + caplog, + Platform.FAN, + config, + discovery_update, + object_id="tasmota", + name="Tasmota", ) @@ -310,7 +322,6 @@ async def test_discovery_device_remove( ) -> None: """Test device registry remove.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 unique_id = f"{DEFAULT_CONFIG['mac']}_fan_fan_ifan" await help_test_discovery_device_remove( @@ -323,7 +334,6 @@ async def test_entity_id_update_subscriptions( ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 topics = [ get_topic_stat_result(config), @@ -331,7 +341,7 @@ async def test_entity_id_update_subscriptions( get_topic_tele_will(config), ] await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, Platform.FAN, config, topics + hass, mqtt_mock, Platform.FAN, config, topics, object_id="tasmota" ) @@ -340,8 +350,7 @@ async def test_entity_id_update_discovery_update( ) -> None: """Test MQTT discovery update when entity_id is updated.""" config = copy.deepcopy(DEFAULT_CONFIG) - config["dn"] = "Test" config["if"] = 1 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, Platform.FAN, config + hass, mqtt_mock, Platform.FAN, config, object_id="tasmota" ) diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 612bda8bb08..5c8339a6f89 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -53,7 +53,7 @@ async def test_attributes_on_off( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") is None assert state.attributes.get("max_mireds") is None @@ -82,7 +82,7 @@ async def test_attributes_dimmer_tuya( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") is None assert state.attributes.get("max_mireds") is None @@ -110,7 +110,7 @@ async def test_attributes_dimmer( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") is None assert state.attributes.get("max_mireds") is None @@ -138,7 +138,7 @@ async def test_attributes_ct( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") == 153 assert state.attributes.get("max_mireds") == 500 @@ -167,7 +167,7 @@ async def test_attributes_ct_reduced( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") is None assert state.attributes.get("min_mireds") == 200 assert state.attributes.get("max_mireds") == 380 @@ -195,7 +195,7 @@ async def test_attributes_rgb( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -232,7 +232,7 @@ async def test_attributes_rgbw( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -269,7 +269,7 @@ async def test_attributes_rgbww( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -307,7 +307,7 @@ async def test_attributes_rgbww_reduced( await hass.async_block_till_done() async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("effect_list") == [ "Solid", "Wake up", @@ -341,37 +341,37 @@ async def test_controlling_state_via_mqtt_on_off( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "onoff" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "onoff" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes @@ -392,32 +392,32 @@ async def test_controlling_state_via_mqtt_ct( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" @@ -425,7 +425,7 @@ async def test_controlling_state_via_mqtt_ct( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","CT":300}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("color_mode") == "color_temp" @@ -434,7 +434,7 @@ async def test_controlling_state_via_mqtt_ct( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Color":"255,128"}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("brightness") == 128 @@ -457,32 +457,32 @@ async def test_controlling_state_via_mqtt_rgbw( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "hs" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"White":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "hs" @@ -490,7 +490,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":75,"White":75}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 191 assert state.attributes.get("color_mode") == "white" @@ -500,7 +500,7 @@ async def test_controlling_state_via_mqtt_rgbw( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"HSBColor":"30,100,50","White":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("hs_color") == (30, 100) @@ -509,7 +509,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("rgb_color") is None @@ -518,7 +518,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 0 assert state.attributes.get("rgb_color") is None @@ -527,18 +527,18 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Scheme":3}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("effect") == "Cycle down" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF @@ -558,32 +558,32 @@ async def test_controlling_state_via_mqtt_rgbww( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" @@ -593,7 +593,7 @@ async def test_controlling_state_via_mqtt_rgbww( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"HSBColor":"30,100,50","White":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -601,7 +601,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color assert "rgb_color" not in state.attributes @@ -610,7 +610,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","CT":300}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("color_mode") == "color_temp" @@ -618,7 +618,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white to 0 should clear the color_temp assert "color_temp" not in state.attributes @@ -628,18 +628,18 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Scheme":3}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("effect") == "Cycle down" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF @@ -660,32 +660,32 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( ) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) assert "color_mode" not in state.attributes async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert "color_mode" not in state.attributes async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" @@ -695,7 +695,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","HSBColor":"30,100,0","White":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -705,7 +705,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":0}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -713,7 +713,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"White":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color assert "rgb_color" not in state.attributes @@ -722,7 +722,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","CT":300}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 assert state.attributes.get("color_mode") == "color_temp" @@ -730,7 +730,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","White":0}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white to 0 should clear the color_temp assert not state.attributes.get("color_temp") @@ -739,18 +739,18 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Scheme":3}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("effect") == "Cycle down" async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF @@ -772,25 +772,25 @@ async def test_sending_mqtt_commands_on_off( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "OFF", 0, False ) @@ -816,32 +816,32 @@ async def test_sending_mqtt_commands_rgbww_tuya( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer3 75", 0, False ) @@ -866,39 +866,39 @@ async def test_sending_mqtt_commands_rgbw_legacy( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75", 0, False ) mqtt_mock.async_publish.reset_mock() # Set color when setting color - await common.async_turn_on(hass, "light.test", hs_color=[0, 100]) + await common.async_turn_on(hass, "light.tasmota_test", hs_color=[0, 100]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 0;NoDelay;HsbColor2 100", @@ -908,7 +908,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( mqtt_mock.async_publish.reset_mock() # Set white when setting white - await common.async_turn_on(hass, "light.test", white=128) + await common.async_turn_on(hass, "light.tasmota_test", white=128) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;White 50", @@ -918,7 +918,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[128, 64, 32, 0]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[128, 64, 32, 0]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 20;NoDelay;HsbColor2 75", @@ -928,7 +928,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[16, 64, 32, 128]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[16, 64, 32, 128]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 141;NoDelay;HsbColor2 25", @@ -937,7 +937,7 @@ async def test_sending_mqtt_commands_rgbw_legacy( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", effect="Random") + await common.async_turn_on(hass, "light.tasmota_test", effect="Random") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;Scheme 4", @@ -965,39 +965,39 @@ async def test_sending_mqtt_commands_rgbw( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75", 0, False ) mqtt_mock.async_publish.reset_mock() # Set color when setting color - await common.async_turn_on(hass, "light.test", hs_color=[180, 50]) + await common.async_turn_on(hass, "light.tasmota_test", hs_color=[180, 50]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 180;NoDelay;HsbColor2 50", @@ -1007,7 +1007,7 @@ async def test_sending_mqtt_commands_rgbw( mqtt_mock.async_publish.reset_mock() # Set white when setting white - await common.async_turn_on(hass, "light.test", white=128) + await common.async_turn_on(hass, "light.tasmota_test", white=128) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;White 50", @@ -1017,7 +1017,7 @@ async def test_sending_mqtt_commands_rgbw( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[128, 64, 32, 0]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[128, 64, 32, 0]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 20;NoDelay;HsbColor2 75", @@ -1027,7 +1027,7 @@ async def test_sending_mqtt_commands_rgbw( mqtt_mock.async_publish.reset_mock() # rgbw_color should be converted - await common.async_turn_on(hass, "light.test", rgbw_color=[16, 64, 32, 128]) + await common.async_turn_on(hass, "light.tasmota_test", rgbw_color=[16, 64, 32, 128]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 141;NoDelay;HsbColor2 25", @@ -1036,7 +1036,7 @@ async def test_sending_mqtt_commands_rgbw( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", effect="Random") + await common.async_turn_on(hass, "light.tasmota_test", effect="Random") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;Scheme 4", @@ -1064,38 +1064,38 @@ async def test_sending_mqtt_commands_rgbww( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75", 0, False ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", hs_color=[240, 75]) + await common.async_turn_on(hass, "light.tasmota_test", hs_color=[240, 75]) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;HsbColor1 240;NoDelay;HsbColor2 75", @@ -1104,7 +1104,7 @@ async def test_sending_mqtt_commands_rgbww( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", color_temp=200) + await common.async_turn_on(hass, "light.tasmota_test", color_temp=200) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;CT 200", @@ -1113,7 +1113,7 @@ async def test_sending_mqtt_commands_rgbww( ) mqtt_mock.async_publish.reset_mock() - await common.async_turn_on(hass, "light.test", effect="Random") + await common.async_turn_on(hass, "light.tasmota_test", effect="Random") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON;NoDelay;Scheme 4", @@ -1142,32 +1142,32 @@ async def test_sending_mqtt_commands_power_unlinked( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT message is sent - await common.async_turn_on(hass, "light.test") + await common.async_turn_on(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF # Turn the light off and verify MQTT message is sent - await common.async_turn_off(hass, "light.test") + await common.async_turn_off(hass, "light.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Power1 OFF", 0, False ) mqtt_mock.async_publish.reset_mock() # Turn the light on and verify MQTT messages are sent; POWER should be sent - await common.async_turn_on(hass, "light.test", brightness=192) + await common.async_turn_on(hass, "light.tasmota_test", brightness=192) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Dimmer 75;NoDelay;Power1 ON", @@ -1195,14 +1195,14 @@ async def test_transition( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=255, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=255, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 100", @@ -1212,7 +1212,9 @@ async def test_transition( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be capped at 40 - await common.async_turn_on(hass, "light.test", brightness=255, transition=100) + await common.async_turn_on( + hass, "light.tasmota_test", brightness=255, transition=100 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Dimmer 100", @@ -1222,7 +1224,7 @@ async def test_transition( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->0: Speed should be 1 - await common.async_turn_on(hass, "light.test", brightness=0, transition=100) + await common.async_turn_on(hass, "light.tasmota_test", brightness=0, transition=100) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 1;NoDelay;Power1 OFF", @@ -1232,7 +1234,7 @@ async def test_transition( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->50: Speed should be 4*2*2=16 - await common.async_turn_on(hass, "light.test", brightness=128, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=128, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 16;NoDelay;Dimmer 50", @@ -1245,12 +1247,12 @@ async def test_transition( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 # Dim the light from 50->0: Speed should be 6*2*2=24 - await common.async_turn_off(hass, "light.test", transition=6) + await common.async_turn_off(hass, "light.tasmota_test", transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 OFF", @@ -1263,12 +1265,12 @@ async def test_transition( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":100}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 # Dim the light from 100->0: Speed should be 0 - await common.async_turn_off(hass, "light.test", transition=0) + await common.async_turn_off(hass, "light.tasmota_test", transition=0) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 0;NoDelay;Power1 OFF", @@ -1286,13 +1288,15 @@ async def test_transition( ' "Color":"0,255,0","HSBColor":"120,100,50","White":0}' ), ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("rgb_color") == (0, 255, 0) # Set color of the light from 0,255,0 to 255,0,0 @ 50%: Speed should be 6*2*2=24 - await common.async_turn_on(hass, "light.test", rgb_color=[255, 0, 0], transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", rgb_color=[255, 0, 0], transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", ( @@ -1310,13 +1314,15 @@ async def test_transition( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":100, "Color":"0,255,0","HSBColor":"120,100,50"}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 255 assert state.attributes.get("rgb_color") == (0, 255, 0) # Set color of the light from 0,255,0 to 255,0,0 @ 100%: Speed should be 6*2=12 - await common.async_turn_on(hass, "light.test", rgb_color=[255, 0, 0], transition=6) + await common.async_turn_on( + hass, "light.tasmota_test", rgb_color=[255, 0, 0], transition=6 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", ( @@ -1334,13 +1340,13 @@ async def test_transition( "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50, "CT":153, "White":50}', ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_temp") == 153 # Set color_temp of the light from 153 to 500 @ 50%: Speed should be 6*2*2=24 - await common.async_turn_on(hass, "light.test", color_temp=500, transition=6) + await common.async_turn_on(hass, "light.tasmota_test", color_temp=500, transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 24;NoDelay;Power1 ON;NoDelay;CT 500", @@ -1353,13 +1359,13 @@ async def test_transition( async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50, "CT":500}' ) - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_temp") == 500 # Set color_temp of the light from 500 to 326 @ 50%: Speed should be 6*2*2*2=48->40 - await common.async_turn_on(hass, "light.test", color_temp=326, transition=6) + await common.async_turn_on(hass, "light.tasmota_test", color_temp=326, transition=6) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Power1 ON;NoDelay;CT 326", @@ -1388,14 +1394,14 @@ async def test_transition_fixed( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=255, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=255, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 100", @@ -1405,7 +1411,9 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->100: Speed should be capped at 40 - await common.async_turn_on(hass, "light.test", brightness=255, transition=100) + await common.async_turn_on( + hass, "light.tasmota_test", brightness=255, transition=100 + ) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 40;NoDelay;Dimmer 100", @@ -1415,7 +1423,7 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->0: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=0, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=0, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Power1 OFF", @@ -1425,7 +1433,7 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->50: Speed should be 4*2=8 - await common.async_turn_on(hass, "light.test", brightness=128, transition=4) + await common.async_turn_on(hass, "light.tasmota_test", brightness=128, transition=4) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 1;NoDelay;Speed2 8;NoDelay;Dimmer 50", @@ -1435,7 +1443,7 @@ async def test_transition_fixed( mqtt_mock.async_publish.reset_mock() # Dim the light from 0->50: Speed should be 0 - await common.async_turn_on(hass, "light.test", brightness=128, transition=0) + await common.async_turn_on(hass, "light.tasmota_test", brightness=128, transition=0) mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Backlog", "NoDelay;Fade2 0;NoDelay;Dimmer 50", @@ -1463,7 +1471,7 @@ async def test_relay_as_light( state = hass.states.get("switch.test") assert state is None - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state is not None @@ -1631,14 +1639,14 @@ async def test_discovery_update_reconfigure_light( # Simple dimmer async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data1) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state.attributes.get("supported_features") == LightEntityFeature.TRANSITION assert state.attributes.get("supported_color_modes") == ["brightness"] # Reconfigure as RGB light async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data2) await hass.async_block_till_done() - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert ( state.attributes.get("supported_features") == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 6a896615c73..4e79b8ad0d5 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -38,7 +38,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, async_fire_time_changed from tests.typing import MqttMockHAClient, MqttMockPahoClient -BAD_INDEXED_SENSOR_CONFIG_3 = { +BAD_LIST_SENSOR_CONFIG_3 = { "sn": { "Time": "2020-09-25T12:47:15", "ENERGY": { @@ -47,7 +47,9 @@ BAD_INDEXED_SENSOR_CONFIG_3 = { } } -INDEXED_SENSOR_CONFIG = { +# This configuration has some sensors where values are lists +# Home Assistant maps this to one sensor for each list item +LIST_SENSOR_CONFIG = { "sn": { "Time": "2020-09-25T12:47:15", "ENERGY": { @@ -74,7 +76,8 @@ INDEXED_SENSOR_CONFIG = { } } -INDEXED_SENSOR_CONFIG_2 = { +# Same as LIST_SENSOR_CONFIG, but Total is also a list +LIST_SENSOR_CONFIG_2 = { "sn": { "Time": "2020-09-25T12:47:15", "ENERGY": { @@ -101,8 +104,9 @@ INDEXED_SENSOR_CONFIG_2 = { } } - -NESTED_SENSOR_CONFIG_1 = { +# This configuration has some sensors where values are dicts +# Home Assistant maps this to one sensor for each dictionary item +DICT_SENSOR_CONFIG_1 = { "sn": { "Time": "2020-03-03T00:00:00+00:00", "TX23": { @@ -119,7 +123,22 @@ NESTED_SENSOR_CONFIG_1 = { } } -NESTED_SENSOR_CONFIG_2 = { +# Similar to LIST_SENSOR_CONFIG, but Total is a dict +DICT_SENSOR_CONFIG_2 = { + "sn": { + "Time": "2023-01-27T11:04:56", + "ENERGY": { + "Total": { + "Phase1": 0.017, + "Phase2": 0.017, + }, + "TotalStartTime": "2018-11-23T15:33:47", + }, + } +} + + +TEMPERATURE_SENSOR_CONFIG = { "sn": { "Time": "2023-01-27T11:04:56", "DS18B20": { @@ -131,65 +150,33 @@ NESTED_SENSOR_CONFIG_2 = { } -async def test_controlling_state_via_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota -) -> None: - """Test state update via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) - mac = config["mac"] - - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/config", - json.dumps(config), - ) - await hass.async_block_till_done() - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/sensors", - json.dumps(sensor_config), - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.tasmota_dht11_temperature") - assert state.state == "unavailable" - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.tasmota_dht11_temperature") - assert entry.disabled is False - assert entry.disabled_by is None - assert entry.entity_category is None - - async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") - await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_dht11_temperature") - assert state.state == STATE_UNKNOWN - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - # Test periodic state update - async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"DHT11":{"Temperature":20.5}}' - ) - state = hass.states.get("sensor.tasmota_dht11_temperature") - assert state.state == "20.5" - - # Test polled state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"DHT11":{"Temperature":20.0}}}', - ) - state = hass.states.get("sensor.tasmota_dht11_temperature") - assert state.state == "20.0" - - @pytest.mark.parametrize( ("sensor_config", "entity_ids", "messages", "states"), [ ( - NESTED_SENSOR_CONFIG_1, + DEFAULT_SENSOR_CONFIG, + ["sensor.tasmota_dht11_temperature"], + ( + '{"DHT11":{"Temperature":20.5}}', + '{"StatusSNS":{"DHT11":{"Temperature":20.0}}}', + ), + ( + { + "sensor.tasmota_dht11_temperature": { + "state": "20.5", + "attributes": { + "device_class": "temperature", + "unit_of_measurement": "°C", + }, + }, + }, + { + "sensor.tasmota_dht11_temperature": {"state": "20.0"}, + }, + ), + ), + ( + DICT_SENSOR_CONFIG_1, ["sensor.tasmota_tx23_speed_act", "sensor.tasmota_tx23_dir_card"], ( '{"TX23":{"Speed":{"Act":"12.3"},"Dir": {"Card": "WSW"}}}', @@ -197,17 +184,50 @@ async def test_controlling_state_via_mqtt( ), ( { - "sensor.tasmota_tx23_speed_act": "12.3", - "sensor.tasmota_tx23_dir_card": "WSW", + "sensor.tasmota_tx23_speed_act": { + "state": "12.3", + "attributes": { + "device_class": None, + "unit_of_measurement": "km/h", + }, + }, + "sensor.tasmota_tx23_dir_card": {"state": "WSW"}, }, { - "sensor.tasmota_tx23_speed_act": "23.4", - "sensor.tasmota_tx23_dir_card": "ESE", + "sensor.tasmota_tx23_speed_act": {"state": "23.4"}, + "sensor.tasmota_tx23_dir_card": {"state": "ESE"}, }, ), ), ( - NESTED_SENSOR_CONFIG_2, + LIST_SENSOR_CONFIG, + [ + "sensor.tasmota_energy_totaltariff_0", + "sensor.tasmota_energy_totaltariff_1", + ], + ( + '{"ENERGY":{"TotalTariff":[1.2,3.4]}}', + '{"StatusSNS":{"ENERGY":{"TotalTariff":[5.6,7.8]}}}', + ), + ( + { + "sensor.tasmota_energy_totaltariff_0": { + "state": "1.2", + "attributes": { + "device_class": None, + "unit_of_measurement": None, + }, + }, + "sensor.tasmota_energy_totaltariff_1": {"state": "3.4"}, + }, + { + "sensor.tasmota_energy_totaltariff_0": {"state": "5.6"}, + "sensor.tasmota_energy_totaltariff_1": {"state": "7.8"}, + }, + ), + ), + ( + TEMPERATURE_SENSOR_CONFIG, ["sensor.tasmota_ds18b20_temperature", "sensor.tasmota_ds18b20_id"], ( '{"DS18B20":{"Id": "01191ED79190","Temperature": 12.3}}', @@ -215,18 +235,117 @@ async def test_controlling_state_via_mqtt( ), ( { - "sensor.tasmota_ds18b20_temperature": "12.3", - "sensor.tasmota_ds18b20_id": "01191ED79190", + "sensor.tasmota_ds18b20_temperature": { + "state": "12.3", + "attributes": { + "device_class": "temperature", + "unit_of_measurement": "°C", + }, + }, + "sensor.tasmota_ds18b20_id": {"state": "01191ED79190"}, }, { - "sensor.tasmota_ds18b20_temperature": "23.4", - "sensor.tasmota_ds18b20_id": "meep", + "sensor.tasmota_ds18b20_temperature": {"state": "23.4"}, + "sensor.tasmota_ds18b20_id": {"state": "meep"}, + }, + ), + ), + # Test simple Total sensor + ( + LIST_SENSOR_CONFIG, + ["sensor.tasmota_energy_total"], + ( + '{"ENERGY":{"Total":1.2,"TotalStartTime":"2018-11-23T15:33:47"}}', + '{"StatusSNS":{"ENERGY":{"Total":5.6,"TotalStartTime":"2018-11-23T16:33:47"}}}', + ), + ( + { + "sensor.tasmota_energy_total": { + "state": "1.2", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + }, + { + "sensor.tasmota_energy_total": {"state": "5.6"}, + }, + ), + ), + # Test list Total sensors + ( + LIST_SENSOR_CONFIG_2, + ["sensor.tasmota_energy_total_0", "sensor.tasmota_energy_total_1"], + ( + '{"ENERGY":{"Total":[1.2, 3.4],"TotalStartTime":"2018-11-23T15:33:47"}}', + '{"StatusSNS":{"ENERGY":{"Total":[5.6, 7.8],"TotalStartTime":"2018-11-23T16:33:47"}}}', + ), + ( + { + "sensor.tasmota_energy_total_0": { + "state": "1.2", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + "sensor.tasmota_energy_total_1": { + "state": "3.4", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + }, + { + "sensor.tasmota_energy_total_0": {"state": "5.6"}, + "sensor.tasmota_energy_total_1": {"state": "7.8"}, + }, + ), + ), + # Test dict Total sensors + ( + DICT_SENSOR_CONFIG_2, + [ + "sensor.tasmota_energy_total_phase1", + "sensor.tasmota_energy_total_phase2", + ], + ( + '{"ENERGY":{"Total":{"Phase1":1.2, "Phase2":3.4},"TotalStartTime":"2018-11-23T15:33:47"}}', + '{"StatusSNS":{"ENERGY":{"Total":{"Phase1":5.6, "Phase2":7.8},"TotalStartTime":"2018-11-23T15:33:47"}}}', + ), + ( + { + "sensor.tasmota_energy_total_phase1": { + "state": "1.2", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + "sensor.tasmota_energy_total_phase2": { + "state": "3.4", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + }, + { + "sensor.tasmota_energy_total_phase1": {"state": "5.6"}, + "sensor.tasmota_energy_total_phase2": {"state": "7.8"}, }, ), ), ], ) -async def test_nested_sensor_state_via_mqtt( +async def test_controlling_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota, @@ -236,6 +355,7 @@ async def test_nested_sensor_state_via_mqtt( states, ) -> None: """Test state update via MQTT.""" + entity_reg = er.async_get(hass) config = copy.deepcopy(DEFAULT_CONFIG) sensor_config = copy.deepcopy(sensor_config) mac = config["mac"] @@ -258,6 +378,11 @@ async def test_nested_sensor_state_via_mqtt( assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) + entry = entity_reg.async_get(entity_id) + assert entry.disabled is False + assert entry.disabled_by is None + assert entry.entity_category is None + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() for entity_id in entity_ids: @@ -269,163 +394,19 @@ async def test_nested_sensor_state_via_mqtt( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", messages[0]) for entity_id in entity_ids: state = hass.states.get(entity_id) - assert state.state == states[0][entity_id] + expected_state = states[0][entity_id] + assert state.state == expected_state["state"] + for attribute, expected in expected_state.get("attributes", {}).items(): + assert state.attributes.get(attribute) == expected # Test polled state update async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/STATUS10", messages[1]) for entity_id in entity_ids: state = hass.states.get(entity_id) - assert state.state == states[1][entity_id] - - -async def test_indexed_sensor_state_via_mqtt( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota -) -> None: - """Test state update via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(INDEXED_SENSOR_CONFIG) - mac = config["mac"] - - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/config", - json.dumps(config), - ) - await hass.async_block_till_done() - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/sensors", - json.dumps(sensor_config), - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.tasmota_energy_totaltariff_1") - assert state.state == "unavailable" - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") - await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_energy_totaltariff_1") - assert state.state == STATE_UNKNOWN - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - # Test periodic state update - async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"ENERGY":{"TotalTariff":[1.2,3.4]}}' - ) - state = hass.states.get("sensor.tasmota_energy_totaltariff_1") - assert state.state == "3.4" - - # Test polled state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"ENERGY":{"TotalTariff":[5.6,7.8]}}}', - ) - state = hass.states.get("sensor.tasmota_energy_totaltariff_1") - assert state.state == "7.8" - - -async def test_indexed_sensor_state_via_mqtt2( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota -) -> None: - """Test state update via MQTT for sensor with last_reset property.""" - config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(INDEXED_SENSOR_CONFIG) - mac = config["mac"] - - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/config", - json.dumps(config), - ) - await hass.async_block_till_done() - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/sensors", - json.dumps(sensor_config), - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.tasmota_energy_total") - assert state.state == "unavailable" - assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL - - async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") - await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_energy_total") - assert state.state == STATE_UNKNOWN - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - # Test periodic state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/tele/SENSOR", - '{"ENERGY":{"Total":1.2,"TotalStartTime":"2018-11-23T15:33:47"}}', - ) - state = hass.states.get("sensor.tasmota_energy_total") - assert state.state == "1.2" - - # Test polled state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"ENERGY":{"Total":5.6,"TotalStartTime":"2018-11-23T16:33:47"}}}', - ) - state = hass.states.get("sensor.tasmota_energy_total") - assert state.state == "5.6" - - -async def test_indexed_sensor_state_via_mqtt3( - hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota -) -> None: - """Test state update via MQTT for indexed sensor with last_reset property.""" - config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(INDEXED_SENSOR_CONFIG_2) - mac = config["mac"] - - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/config", - json.dumps(config), - ) - await hass.async_block_till_done() - async_fire_mqtt_message( - hass, - f"{DEFAULT_PREFIX}/{mac}/sensors", - json.dumps(sensor_config), - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.tasmota_energy_total_1") - assert state.state == "unavailable" - assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL - - async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") - await hass.async_block_till_done() - state = hass.states.get("sensor.tasmota_energy_total_1") - assert state.state == STATE_UNKNOWN - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - # Test periodic state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/tele/SENSOR", - '{"ENERGY":{"Total":[1.2, 3.4],"TotalStartTime":"2018-11-23T15:33:47"}}', - ) - state = hass.states.get("sensor.tasmota_energy_total_1") - assert state.state == "3.4" - - # Test polled state update - async_fire_mqtt_message( - hass, - "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"ENERGY":{"Total":[5.6,7.8],"TotalStartTime":"2018-11-23T16:33:47"}}}', - ) - state = hass.states.get("sensor.tasmota_energy_total_1") - assert state.state == "7.8" + expected_state = states[1][entity_id] + assert state.state == expected_state["state"] + for attribute, expected in expected_state.get("attributes", {}).items(): + assert state.attributes.get(attribute) == expected async def test_bad_indexed_sensor_state_via_mqtt( @@ -433,7 +414,7 @@ async def test_bad_indexed_sensor_state_via_mqtt( ) -> None: """Test state update via MQTT where sensor is not matching configuration.""" config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(BAD_INDEXED_SENSOR_CONFIG_3) + sensor_config = copy.deepcopy(BAD_LIST_SENSOR_CONFIG_3) mac = config["mac"] async_fire_mqtt_message( @@ -604,6 +585,48 @@ async def test_status_sensor_state_via_mqtt( assert not entity.force_update +@pytest.mark.parametrize("status_sensor_disabled", [False]) +async def test_battery_sensor_state_via_mqtt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test state update via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["bat"] = 1 # BatteryPercentage feature enabled + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get("sensor.tasmota_battery_level") + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_battery_level") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Test pushed state update + async_fire_mqtt_message( + hass, "tasmota_49A3BC/tele/STATE", '{"BatteryPercentage":55}' + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_battery_level") + assert state.state == "55" + assert state.attributes == { + "device_class": "battery", + "friendly_name": "Tasmota Battery Level", + "state_class": "measurement", + "unit_of_measurement": "%", + } + + @pytest.mark.parametrize("status_sensor_disabled", [False]) async def test_single_shot_status_sensor_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota @@ -784,7 +807,7 @@ async def test_nested_sensor_attributes( ) -> None: """Test correct attributes for sensors.""" config = copy.deepcopy(DEFAULT_CONFIG) - sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG_1) + sensor_config = copy.deepcopy(DICT_SENSOR_CONFIG_1) mac = config["mac"] async_fire_mqtt_message( diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index b79560214a8..b8d0ed2d060 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -47,34 +47,34 @@ async def test_controlling_state_via_mqtt( ) await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_ON async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"OFF"}') - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF @@ -95,30 +95,30 @@ async def test_sending_mqtt_commands( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF await hass.async_block_till_done() await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Turn the switch on and verify MQTT message is sent - await common.async_turn_on(hass, "switch.test") + await common.async_turn_on(hass, "switch.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "ON", 0, False ) mqtt_mock.async_publish.reset_mock() # Tasmota is not optimistic, the state should still be off - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF # Turn the switch off and verify MQTT message is sent - await common.async_turn_off(hass, "switch.test") + await common.async_turn_off(hass, "switch.tasmota_test") mqtt_mock.async_publish.assert_called_once_with( "tasmota_49A3BC/cmnd/Power1", "OFF", 0, False ) - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state.state == STATE_OFF @@ -138,9 +138,9 @@ async def test_relay_as_light( ) await hass.async_block_till_done() - state = hass.states.get("switch.test") + state = hass.states.get("switch.tasmota_test") assert state is None - state = hass.states.get("light.test") + state = hass.states.get("light.tasmota_test") assert state is not None diff --git a/tests/components/tautulli/test_config_flow.py b/tests/components/tautulli/test_config_flow.py index d39f9c1e3a1..0ca2d0438a7 100644 --- a/tests/components/tautulli/test_config_flow.py +++ b/tests/components/tautulli/test_config_flow.py @@ -3,11 +3,11 @@ from unittest.mock import AsyncMock, patch from pytautulli import exceptions -from homeassistant import data_entry_flow from homeassistant.components.tautulli.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from . import CONF_DATA, NAME, patch_config_flow_tautulli, setup_integration @@ -20,7 +20,7 @@ async def test_flow_user(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} @@ -31,7 +31,7 @@ async def test_flow_user(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == NAME assert result2["data"] == CONF_DATA @@ -43,7 +43,7 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" @@ -54,7 +54,7 @@ async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == NAME assert result2["data"] == CONF_DATA @@ -66,7 +66,7 @@ async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" @@ -77,7 +77,7 @@ async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == NAME assert result2["data"] == CONF_DATA @@ -89,7 +89,7 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "unknown" @@ -100,7 +100,7 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == NAME assert result2["data"] == CONF_DATA @@ -116,7 +116,7 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: context={"source": SOURCE_USER}, data=CONF_DATA, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -128,7 +128,7 @@ async def test_flow_user_multiple_entries_allowed(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} @@ -144,7 +144,7 @@ async def test_flow_user_multiple_entries_allowed(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == NAME assert result2["data"] == input @@ -164,7 +164,7 @@ async def test_flow_reauth( }, data=CONF_DATA, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} @@ -179,7 +179,7 @@ async def test_flow_reauth( ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "reauth_successful" assert entry.data == CONF_DATA assert len(mock_entry.mock_calls) == 1 @@ -205,7 +205,7 @@ async def test_flow_reauth_error( result["flow_id"], user_input={CONF_API_KEY: "efgh"}, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"]["base"] == "invalid_auth" @@ -214,5 +214,5 @@ async def test_flow_reauth_error( result["flow_id"], user_input={CONF_API_KEY: "efgh"}, ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 1e6b2cc3840..e43163f66fc 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,5 +1,5 @@ """The tests for the Template Binary sensor platform.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging from unittest.mock import patch @@ -1276,9 +1276,7 @@ async def test_trigger_entity_restore_state_auto_off( fake_extra_data = { "auto_off_time": { "__type": "", - "isoformat": datetime( - 2022, 2, 2, 12, 2, 2, tzinfo=timezone.utc - ).isoformat(), + "isoformat": datetime(2022, 2, 2, 12, 2, 2, tzinfo=UTC).isoformat(), }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) @@ -1336,9 +1334,7 @@ async def test_trigger_entity_restore_state_auto_off_expired( fake_extra_data = { "auto_off_time": { "__type": "", - "isoformat": datetime( - 2022, 2, 2, 12, 2, 0, tzinfo=timezone.utc - ).isoformat(), + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index f3fd3e03ce0..bfdb9352767 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -97,7 +97,7 @@ async def test_all_optional_config(hass: HomeAssistant, calls) -> None: _TEST_OPTIONS_BUTTON, ) - now = dt.datetime.now(dt.timezone.utc) + now = dt.datetime.now(dt.UTC) with patch("homeassistant.util.dt.utcnow", return_value=now): await hass.services.async_call( diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py new file mode 100644 index 00000000000..f4cfe90b9f0 --- /dev/null +++ b/tests/components/template/test_config_flow.py @@ -0,0 +1,848 @@ +"""Test the Switch config flow.""" +from typing import Any +from unittest.mock import patch + +import pytest +from pytest_unordered import unordered + +from homeassistant import config_entries +from homeassistant.components.template import DOMAIN, async_setup_entry +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "template_state", + "input_states", + "input_attributes", + "extra_input", + "extra_options", + "extra_attrs", + ), + ( + ( + "binary_sensor", + "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}", + "on", + {"one": "on", "two": "off"}, + {}, + {}, + {}, + {}, + ), + ( + "sensor", + "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + "50.0", + {"one": "30.0", "two": "20.0"}, + {}, + {}, + {}, + {}, + ), + ), +) +async def test_config_flow( + hass: HomeAssistant, + template_type, + state_template, + template_state, + input_states, + input_attributes, + extra_input, + extra_options, + extra_attrs, +) -> None: + """Test the config flow.""" + input_entities = ["one", "two"] + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", + input_states[input_entity], + input_attributes.get(input_entity, {}), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + + with patch( + "homeassistant.components.template.async_setup_entry", wraps=async_setup_entry + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My template", + "state": state_template, + **extra_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My template" + assert result["data"] == {} + assert result["options"] == { + "name": "My template", + "state": state_template, + "template_type": template_type, + **extra_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "name": "My template", + "state": state_template, + "template_type": template_type, + **extra_options, + } + + state = hass.states.get(f"{template_type}.my_template") + assert state.state == template_state + for key in extra_attrs: + assert state.attributes[key] == extra_attrs[key] + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema: + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + +@pytest.mark.parametrize( + ( + "template_type", + "old_state_template", + "new_state_template", + "template_state", + "input_states", + "extra_options", + "options_options", + ), + ( + ( + "binary_sensor", + "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}", + "{{ states('binary_sensor.one') == 'on' and states('binary_sensor.two') == 'on' }}", + ["on", "off"], + {"one": "on", "two": "off"}, + {}, + {}, + ), + ( + "sensor", + "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + "{{ float(states('sensor.one')) - float(states('sensor.two')) }}", + ["50.0", "10.0"], + {"one": "30.0", "two": "20.0"}, + {}, + {}, + ), + ), +) +async def test_options( + hass: HomeAssistant, + template_type, + old_state_template, + new_state_template, + template_state, + input_states, + extra_options, + options_options, +) -> None: + """Test reconfiguring.""" + input_entities = ["one", "two"] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) + + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "state": old_state_template, + "template_type": template_type, + **extra_options, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{template_type}.my_template") + assert state.state == template_state[0] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert get_suggested(result["data_schema"].schema, "state") == old_state_template + assert "name" not in result["data_schema"].schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"state": new_state_template, **options_options}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "My template", + "state": new_state_template, + "template_type": template_type, + **extra_options, + } + assert config_entry.data == {} + assert config_entry.options == { + "name": "My template", + "state": new_state_template, + "template_type": template_type, + **extra_options, + } + assert config_entry.title == "My template" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + state = hass.states.get(f"{template_type}.my_template") + assert state.state == template_state[1] + + # Check we don't get suggestions from another entry + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + + assert get_suggested(result["data_schema"].schema, "name") is None + assert get_suggested(result["data_schema"].schema, "state") is None + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "extra_user_input", + "input_states", + "template_states", + "extra_attributes", + "listeners", + ), + ( + ( + "binary_sensor", + "{{ states.binary_sensor.one.state == 'on' or states.binary_sensor.two.state == 'on' }}", + {}, + {"one": "on", "two": "off"}, + ["off", "on"], + [{}, {}], + [["one", "two"], ["one"]], + ), + ( + "sensor", + "{{ float(states('sensor.one'), default='') + float(states('sensor.two'), default='') }}", + {}, + {"one": "30.0", "two": "20.0"}, + ["", "50.0"], + [{}, {}], + [["one", "two"], ["one", "two"]], + ), + ), +) +async def test_config_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + extra_user_input: dict[str, Any], + input_states: list[str], + template_states: str, + extra_attributes: list[dict[str, Any]], + listeners: list[list[str]], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template} + | extra_user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My template"} | extra_attributes[0], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[0]]), + "time": False, + }, + "state": template_states[0], + } + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My template"} + | extra_attributes[0] + | extra_attributes[1], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[1]]), + "time": False, + }, + "state": template_states[1], + } + assert len(hass.states.async_all()) == 2 + + +EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of template')" + + +@pytest.mark.parametrize( + ("template_type", "state_template", "extra_user_input", "error"), + [ + ("binary_sensor", "{{", {}, {"state": EARLY_END_ERROR}), + ("sensor", "{{", {}, {"state": EARLY_END_ERROR}), + ( + "sensor", + "", + {"device_class": "aqi", "unit_of_measurement": "cats"}, + { + "unit_of_measurement": ( + "'cats' is not a valid unit for device class 'aqi'; " + "expected no unit of measurement" + ), + }, + ), + ( + "sensor", + "", + {"device_class": "temperature", "unit_of_measurement": "cats"}, + { + "unit_of_measurement": ( + "'cats' is not a valid unit for device class 'temperature'; " + "expected one of 'K', '°C', '°F'" + ), + }, + ), + ( + "sensor", + "", + {"device_class": "timestamp", "state_class": "measurement"}, + { + "state_class": ( + "'measurement' is not a valid state class for device class " + "'timestamp'; expected no state class" + ), + }, + ), + ( + "sensor", + "", + {"device_class": "aqi", "state_class": "total"}, + { + "state_class": ( + "'total' is not a valid state class for device class " + "'aqi'; expected 'measurement'" + ), + }, + ), + ( + "sensor", + "", + {"device_class": "energy", "state_class": "measurement"}, + { + "state_class": ( + "'measurement' is not a valid state class for device class " + "'energy'; expected one of 'total', 'total_increasing'" + ), + "unit_of_measurement": ( + "'None' is not a valid unit for device class 'energy'; " + "expected one of 'GJ', 'kWh', 'MJ', 'MWh', 'Wh'" + ), + }, + ), + ], +) +async def test_config_flow_preview_bad_input( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + extra_user_input: dict[str, str], + error: str, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template} + | extra_user_input, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_user_input", + "message": error, + } + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + {"one": "30.0", "two": "20.0"}, + ["unavailable", "50.0"], + [ + ( + "ValueError: Template error: float got invalid input 'unknown' " + "when rendering template '{{ float(states('sensor.one')) + " + "float(states('sensor.two')) }}' but no default was specified" + ) + ], + ), + ], +) +async def test_config_flow_preview_template_startup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: dict[str, str], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) > 30 and undefined_function() }}", + [{"one": "30.0", "two": "20.0"}, {"one": "35.0", "two": "20.0"}], + ["False", "unavailable"], + ["'undefined_function' is undefined"], + ), + ], +) +async def test_config_flow_preview_template_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: list[dict[str, str]], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[0][input_entity], {} + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[1][input_entity], {} + ) + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "extra_user_input", + ), + ( + ( + "sensor", + "{{ states('sensor.one') }}", + {"unit_of_measurement": "°C"}, + ), + ), +) +async def test_config_flow_preview_bad_state( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + extra_user_input: dict[str, Any], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template} + | extra_user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "error": ( + "Sensor None has device class 'None', state class 'None' unit '°C' " + "and suggested precision 'None' thus indicating it has a numeric " + "value; however, it has the non-numeric value: 'unknown' ()" + ), + } + + +@pytest.mark.parametrize( + ( + "template_type", + "old_state_template", + "new_state_template", + "extra_config_flow_data", + "extra_user_input", + "input_states", + "template_state", + "extra_attributes", + "listeners", + ), + [ + ( + "binary_sensor", + "{{ states('binary_sensor.one') == 'on' or states('binary_sensor.two') == 'on' }}", + "{{ states('binary_sensor.one') == 'on' and states('binary_sensor.two') == 'on' }}", + {}, + {}, + {"one": "on", "two": "off"}, + "off", + {}, + ["one", "two"], + ), + ( + "sensor", + "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + "{{ float(states('sensor.one')) - float(states('sensor.two')) }}", + {}, + {}, + {"one": "30.0", "two": "20.0"}, + "10.0", + {}, + ["one", "two"], + ), + ], +) +async def test_option_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + old_state_template: str, + new_state_template: str, + extra_config_flow_data: dict[str, Any], + extra_user_input: dict[str, Any], + input_states: list[str], + template_state: str, + extra_attributes: dict[str, Any], + listeners: list[str], +) -> None: + """Test the option flow preview.""" + client = await hass_ws_client(hass) + + input_entities = input_entities = ["one", "two"] + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "state": old_state_template, + "template_type": template_type, + } + | extra_config_flow_data, + title="My template", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "template" + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": {"state": new_state_template} | extra_user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My template"} | extra_attributes, + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners]), + "time": False, + }, + "state": template_state, + } + assert len(hass.states.async_all()) == 3 + + +async def test_option_flow_sensor_preview_config_entry_removed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My template", + "state": "Hello!", + "template_type": "sensor", + }, + title="My template", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "template" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": {"state": "Goodbye!"}, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} diff --git a/tests/components/template/test_image.py b/tests/components/template/test_image.py index 17b84e327b1..7b399e13ec0 100644 --- a/tests/components/template/test_image.py +++ b/tests/components/template/test_image.py @@ -14,11 +14,7 @@ from homeassistant.components.input_text import ( DOMAIN as INPUT_TEXT_DOMAIN, SERVICE_SET_VALUE as INPUT_TEXT_SERVICE_SET_VALUE, ) -from homeassistant.const import ( - ATTR_ENTITY_PICTURE, - CONF_ENTITY_ID, - STATE_UNKNOWN, -) +from homeassistant.const import ATTR_ENTITY_PICTURE, CONF_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util diff --git a/tests/components/template/test_manual_trigger_entity.py b/tests/components/template/test_manual_trigger_entity.py index 19210645a0f..a18827ecb4c 100644 --- a/tests/components/template/test_manual_trigger_entity.py +++ b/tests/components/template/test_manual_trigger_entity.py @@ -2,7 +2,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.template_entity import ManualTriggerEntity +from homeassistant.helpers.trigger_template_entity import ManualTriggerEntity async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index d3e3ebf5812..1bd1e797c05 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -725,8 +725,9 @@ async def test_this_variable_early_hass_not_running( # Signal hass started hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() + await hass.async_block_till_done() - # Re-render icon, name, pciture + other templates now rendered + # icon, name, picture + other templates now re-rendered state = hass.states.get(entity_id) assert state.state == "sensor.none_false: sensor.none_false" assert state.attributes == { @@ -1529,3 +1530,47 @@ async def test_trigger_entity_restore_state( assert state.attributes["entity_picture"] == "/local/dogs.png" assert state.attributes["plus_one"] == 3 assert state.attributes["another"] == 1 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.beer + 1 }}" + }, + }, + ], + "sensor": [ + { + "name": "Hello Name", + "state": "{{ my_variable + 1 }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_action( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity with an action works.""" + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == "3" + assert state.context is context diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 38cf439987d..97965a5643e 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components.weather import ( + ATTR_FORECAST, ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, @@ -13,13 +14,15 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, - DOMAIN, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + Forecast, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", [ @@ -74,3 +77,419 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: assert state is not None assert state.state == "sunny" assert state.attributes.get(v_attr) == value + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecasts(hass: HomeAssistant, start_ha) -> None: + """Test forecast service.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + ) + ] + }, + ) + hass.states.async_set( + "weather.forecast_twice_daily", + "fog", + { + ATTR_FORECAST: [ + Forecast( + condition="fog", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + is_daytime=True, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast") + assert state is not None + assert state.state == "sunny" + state2 = hass.states.get("weather.forecast_twice_daily") + assert state2 is not None + assert state2.state == "fog" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + } + ] + } + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + } + ] + } + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "fog", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + "is_daytime": True, + } + ] + } + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=16.9, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 16.9, + } + ] + } + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast_hourly.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test invalid forecasts.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + not_correct=1, + ) + ] + }, + ) + hass.states.async_set( + "weather.forecast_hourly", + "sunny", + {ATTR_FORECAST: None}, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_hourly") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "Only valid keys in Forecast are allowed" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid_is_daytime_missing_in_twice_daily( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_twice_daily", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_twice_daily") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "`is_daytime` is missing in twice_daily forecast" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid_datetime_missing( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid when datetime missing.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_twice_daily", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + temperature=14.2, + is_daytime=True, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_twice_daily") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "`datetime` is required in forecasts" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast_daily.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast_hourly.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_format_error( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid on incorrect format.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_daily", + "sunny", + { + ATTR_FORECAST: [ + "cloudy", + "2023-02-17T14:00:00+00:00", + 14.2, + 1, + ] + }, + ) + hass.states.async_set( + "weather.forecast_hourly", + "sunny", + { + ATTR_FORECAST: { + "condition": "cloudy", + "temperature": 14.2, + "is_daytime": True, + } + }, + ) + + await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert "Forecasts is not a list, see Weather documentation" in caplog.text + await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert "Forecast in list is not a dict, see Weather documentation" in caplog.text diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index e7435b8e94a..7ca6cbaf2ed 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -93,6 +93,7 @@ ROUTER_DISCOVERY_HASS = { "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", @@ -105,6 +106,7 @@ ROUTER_DISCOVERY_HASS = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -119,6 +121,7 @@ ROUTER_DISCOVERY_HASS_BAD_DATA = { "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\xff", # Invalid UTF-8 b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", @@ -131,6 +134,7 @@ ROUTER_DISCOVERY_HASS_BAD_DATA = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -145,6 +149,8 @@ ROUTER_DISCOVERY_HASS_MISSING_DATA = { "server": "core-silabs-multiprotocol.local.", "properties": { b"rv": b"1", + b"id": b"#\x0cj\x1a\xc5\x7foK\xe2b\xac\xf3.^\xf5,", + # vn is missing b"mn": b"OpenThreadBorderRouter", b"nn": b"OpenThread HC", b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5", @@ -156,12 +162,13 @@ ROUTER_DISCOVERY_HASS_MISSING_DATA = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } -ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { +ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XA = { "type_": "_meshcop._udp.local.", "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", "addresses": [b"\xc0\xa8\x00s"], @@ -171,6 +178,7 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { "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", @@ -182,6 +190,35 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XP = { + "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"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, } @@ -197,6 +234,7 @@ ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { "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", @@ -208,6 +246,7 @@ ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -223,6 +262,7 @@ ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP = { "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", @@ -234,6 +274,7 @@ ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -249,6 +290,7 @@ ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP = { "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", @@ -261,6 +303,7 @@ ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP = { b"sq": b"3", b"bb": b"\xf0\xbf", b"dn": b"DefaultDomain", + b"omr": b"@\xfd \xbe\x89IZ\x00\x01", }, "interface_index": None, } @@ -276,6 +319,7 @@ ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE = { "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", @@ -288,6 +332,7 @@ ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE = { 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/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 1ed754dbdcd..d8822a7d536 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -254,7 +254,7 @@ 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"]) + store1.async_add(dataset["source"], dataset["tlv"], None) assert len(store1.datasets) == 3 for dataset in store1.datasets.values(): @@ -303,18 +303,21 @@ async def test_loading_datasets_from_storage( { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id1", + "preferred_border_agent_id": "230C6A1AC57F6F4BE262ACF32E5EF52C", "source": "source_1", "tlv": DATASET_1, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id2", + "preferred_border_agent_id": None, "source": "source_2", "tlv": DATASET_2, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id3", + "preferred_border_agent_id": None, "source": "source_3", "tlv": DATASET_3, }, @@ -512,3 +515,63 @@ async def test_migrate_drop_duplicate_datasets_preferred( f"Dropped duplicated Thread dataset '{DATASET_1_LARGER_TIMESTAMP}' " f"(duplicate of preferred dataset '{DATASET_1}')" ) in caplog.text + + +async def test_migrate_set_default_border_agent_id( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store adds default border agent.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": 1, + "minor_version": 2, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + ], + "preferred_dataset": "id1", + }, + } + + store = await dataset_store.async_get_store(hass) + assert store.datasets[store._preferred_dataset].preferred_border_agent_id 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 + + 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) == 1 + assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_border_agent_id="bleh" + ) + assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + + 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 + + await dataset_store.async_add_dataset( + hass, "source", DATASET_2, preferred_border_agent_id="blah" + ) + assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" + + 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 + + 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 == "blah" diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py index a551315205b..94ca4373715 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -106,6 +106,48 @@ TEST_ZEROCONF_RECORD_4 = ServiceInfo( # Make sure this generates an invalid DNSPointer TEST_ZEROCONF_RECORD_4.name = "office._meshcop._udp.lo\x00cal." +# This has no XA +TEST_ZEROCONF_RECORD_5 = ServiceInfo( + type_="_meshcop._udp.local.", + name="bad_1._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e0"], + port=8080, + properties={ + "rv": "1", + "vn": "Apple", + "nn": "OpenThread HC", + "xp": "\xe6\x0f\xc7\xc1\x86!,\xe5", + "tv": "1.2.0", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) + +# This has no XP +TEST_ZEROCONF_RECORD_6 = ServiceInfo( + type_="_meshcop._udp.local.", + name="bad_2._meshcop._udp.local.", + addresses=["127.0.0.1", "fe80::10ed:6406:4ee9:85e0"], + port=8080, + properties={ + "rv": "1", + "vn": "Apple", + "nn": "OpenThread HC", + "tv": "1.2.0", + "xa": "\xae\xeb/YKW\x0b\xbf", + "sb": "\x00\x00\x01\xb1", + "at": "\x00\x00\x00\x00\x00\x01\x00\x00", + "pt": "\x8f\x06Q~", + "sq": "3", + "bb": "\xf0\xbf", + "dn": "DefaultDomain", + }, +) + @dataclasses.dataclass class MockRoute: @@ -177,6 +219,24 @@ async def test_diagnostics( TEST_ZEROCONF_RECORD_4.dns_pointer(created=now), ] ) + # Test for record without xa + cache.async_add_records( + [ + *TEST_ZEROCONF_RECORD_5.dns_addresses(created=now), + TEST_ZEROCONF_RECORD_5.dns_service(created=now), + TEST_ZEROCONF_RECORD_5.dns_text(created=now), + TEST_ZEROCONF_RECORD_5.dns_pointer(created=now), + ] + ) + # Test for record without xp + cache.async_add_records( + [ + *TEST_ZEROCONF_RECORD_6.dns_addresses(created=now), + TEST_ZEROCONF_RECORD_6.dns_service(created=now), + TEST_ZEROCONF_RECORD_6.dns_text(created=now), + TEST_ZEROCONF_RECORD_6.dns_pointer(created=now), + ] + ) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index 84fe4c30974..12eddb0b92a 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -16,7 +16,8 @@ from . import ( ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, ROUTER_DISCOVERY_HASS_MISSING_DATA, - ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA, + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XA, + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XP, ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, @@ -72,6 +73,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], + border_agent_id="230c6a1ac57f6f4be262acf32e5ef52c", brand="homeassistant", extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", @@ -98,6 +100,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) "f6a99b425a67abed", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.124"], + border_agent_id="bc3740c3e963aa8735bebecd7cc503c7", brand="google", extended_address="f6a99b425a67abed", extended_pan_id="9e75e256f61409a3", @@ -150,7 +153,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) async def test_discover_routers_unconfigured( hass: HomeAssistant, mock_async_zeroconf: None, data, unconfigured ) -> None: - """Test discovering thread routers with bad or missing vendor mDNS data.""" + """Test discovering thread routers and setting the unconfigured flag.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() mock_async_zeroconf.async_remove_service_listener = AsyncMock() mock_async_zeroconf.async_get_service_info = AsyncMock() @@ -176,6 +179,7 @@ async def test_discover_routers_unconfigured( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], + border_agent_id="230c6a1ac57f6f4be262acf32e5ef52c", brand="homeassistant", extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", @@ -192,7 +196,7 @@ async def test_discover_routers_unconfigured( @pytest.mark.parametrize( "data", (ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_MISSING_DATA) ) -async def test_discover_routers_bad_data( +async def test_discover_routers_bad_or_missing_optional_data( hass: HomeAssistant, mock_async_zeroconf: None, data ) -> None: """Test discovering thread routers with bad or missing vendor mDNS data.""" @@ -221,6 +225,7 @@ async def test_discover_routers_bad_data( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], + border_agent_id="230c6a1ac57f6f4be262acf32e5ef52c", brand=None, extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", @@ -234,8 +239,15 @@ async def test_discover_routers_bad_data( ) -async def test_discover_routers_missing_mandatory_data( - hass: HomeAssistant, mock_async_zeroconf: None +@pytest.mark.parametrize( + "service", + [ + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XA, + ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA_XP, + ], +) +async def test_discover_routers_bad_or_missing_mandatory_data( + hass: HomeAssistant, mock_async_zeroconf: None, service ) -> None: """Test discovering thread routers with missing mandatory mDNS data.""" mock_async_zeroconf.async_add_service_listener = AsyncMock() @@ -257,12 +269,12 @@ async def test_discover_routers_missing_mandatory_data( # Discover a service with missing mandatory data mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo( - **ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA + **service ) listener.add_service( None, - ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA["type_"], - ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA["name"], + service["type_"], + service["name"], ) await hass.async_block_till_done() router_discovered_removed.assert_not_called() diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 0db16318db1..75e1b313132 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -160,6 +160,7 @@ async def test_list_get_dataset( "network_name": "OpenThreadDemo", "pan_id": "1234", "preferred": True, + "preferred_border_agent_id": None, "source": "Google", }, { @@ -170,6 +171,7 @@ async def test_list_get_dataset( "network_name": "HomeAssistant!", "pan_id": "1234", "preferred": False, + "preferred_border_agent_id": None, "source": "Multipan", }, { @@ -180,6 +182,7 @@ async def test_list_get_dataset( "network_name": "~🐣🐥🐤~", "pan_id": "1234", "preferred": False, + "preferred_border_agent_id": None, "source": "🎅", }, ] @@ -200,6 +203,47 @@ async def test_list_get_dataset( assert msg["error"] == {"code": "not_found", "message": "unknown dataset"} +async def test_set_preferred_border_agent_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test setting the preferred border agent ID.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + {"type": "thread/add_dataset_tlv", "source": "test", "tlv": DATASET_1} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + await client.send_json_auto_id({"type": "thread/list_datasets"}) + msg = await client.receive_json() + assert msg["success"] + datasets = msg["result"]["datasets"] + dataset_id = datasets[0]["dataset_id"] + assert datasets[0]["preferred_border_agent_id"] is None + + await client.send_json_auto_id( + { + "type": "thread/set_preferred_border_agent_id", + "dataset_id": dataset_id, + "border_agent_id": "blah", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + await client.send_json_auto_id({"type": "thread/list_datasets"}) + msg = await client.receive_json() + assert msg["success"] + datasets = msg["result"]["datasets"] + assert datasets[0]["preferred_border_agent_id"] == "blah" + + async def test_set_preferred_dataset( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -288,6 +332,7 @@ async def test_discover_routers( "event": { "data": { "addresses": ["192.168.0.115"], + "border_agent_id": "230c6a1ac57f6f4be262acf32e5ef52c", "brand": "homeassistant", "extended_address": "aeeb2f594b570bbf", "extended_pan_id": "e60fc7c186212ce5", @@ -317,6 +362,7 @@ async def test_discover_routers( "event": { "data": { "addresses": ["192.168.0.124"], + "border_agent_id": "bc3740c3e963aa8735bebecd7cc503c7", "brand": "google", "extended_address": "f6a99b425a67abed", "extended_pan_id": "9e75e256f61409a3", diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index e26781029c5..c4b1dad78d5 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -597,6 +597,7 @@ async def test_device_id(hass: HomeAssistant) -> None: entity_registry = er.async_get(hass) source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, identifiers={("sensor", "identifier_test")}, diff --git a/tests/components/tile/snapshots/test_diagnostics.ambr b/tests/components/tile/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c04bd93315f --- /dev/null +++ b/tests/components/tile/snapshots/test_diagnostics.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'tiles': list([ + dict({ + 'accuracy': 13.496111, + 'altitude': '**REDACTED**', + 'archetype': 'WALLET', + 'dead': False, + 'firmware_version': '01.12.14.0', + 'hardware_version': '02.09', + 'kind': 'TILE', + 'last_timestamp': '2020-08-12T17:55:26', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'lost': False, + 'lost_timestamp': '1969-12-31T23:59:59.999000', + 'name': 'Wallet', + 'ring_state': 'STOPPED', + 'uuid': '**REDACTED**', + 'visible': True, + 'voip_state': 'OFFLINE', + }), + ]), + }) +# --- diff --git a/tests/components/tile/test_diagnostics.py b/tests/components/tile/test_diagnostics.py index a4aa42cc1fb..8af2c513202 100644 --- a/tests/components/tile/test_diagnostics.py +++ b/tests/components/tile/test_diagnostics.py @@ -1,5 +1,6 @@ """Test Tile diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,28 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "tiles": [ - { - "accuracy": 13.496111, - "altitude": REDACTED, - "archetype": "WALLET", - "dead": False, - "firmware_version": "01.12.14.0", - "hardware_version": "02.09", - "kind": "TILE", - "last_timestamp": "2020-08-12T17:55:26", - "latitude": REDACTED, - "longitude": REDACTED, - "lost": False, - "lost_timestamp": "1969-12-31T23:59:59.999000", - "name": "Wallet", - "ring_state": "STOPPED", - "uuid": REDACTED, - "visible": True, - "voip_state": "OFFLINE", - } - ] - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 42f1e260280..96c7edf422b 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -102,7 +102,6 @@ async def test_states_non_default_timezone(hass: HomeAssistant) -> None: assert device.state == "2017-05-17T20:54:00" -# pylint: disable=no-member async def test_timezone_intervals(hass: HomeAssistant) -> None: """Test date sensor behavior in a timezone besides UTC.""" hass.config.set_time_zone("America/New_York") diff --git a/tests/components/tomorrowio/fixtures/v4.json b/tests/components/tomorrowio/fixtures/v4.json index 0ca4f348956..c511263fb5f 100644 --- a/tests/components/tomorrowio/fixtures/v4.json +++ b/tests/components/tomorrowio/fixtures/v4.json @@ -908,6 +908,8 @@ "values": { "temperatureMin": 44.13, "temperatureMax": 44.13, + "dewPoint": 12.76, + "humidity": 58.46, "windSpeed": 9.33, "windDirection": 315.14, "weatherCode": 1000, @@ -2206,6 +2208,8 @@ "values": { "temperatureMin": 26.11, "temperatureMax": 45.93, + "dewPoint": 12.76, + "humidity": 58.46, "windSpeed": 9.49, "windDirection": 239.6, "weatherCode": 1000, diff --git a/tests/components/tomorrowio/snapshots/test_weather.ambr b/tests/components/tomorrowio/snapshots/test_weather.ambr new file mode 100644 index 00000000000..a938cb10e44 --- /dev/null +++ b/tests/components/tomorrowio/snapshots/test_weather.ambr @@ -0,0 +1,1109 @@ +# serializer version: 1 +# name: test_forecast_subscription[daily] + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]) +# --- +# name: test_forecast_subscription[daily].1 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]) +# --- +# name: test_v4_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T11:00:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.9, + 'templow': 26.1, + 'wind_bearing': 239.6, + 'wind_speed': 34.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 49.4, + 'templow': 26.3, + 'wind_bearing': 262.82, + 'wind_speed': 26.06, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-09T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 67.0, + 'templow': 31.5, + 'wind_bearing': 229.3, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-10T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 65.3, + 'templow': 37.3, + 'wind_bearing': 149.91, + 'wind_speed': 38.3, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-11T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 66.2, + 'templow': 48.3, + 'wind_bearing': 210.45, + 'wind_speed': 56.48, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-12T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 67.9, + 'templow': 53.8, + 'wind_bearing': 217.98, + 'wind_speed': 44.28, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-13T11:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 25, + 'temperature': 54.5, + 'templow': 42.9, + 'wind_bearing': 58.79, + 'wind_speed': 34.99, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-14T10:00:00+00:00', + 'precipitation': 0.94, + 'precipitation_probability': 95, + 'temperature': 42.9, + 'templow': 33.4, + 'wind_bearing': 70.25, + 'wind_speed': 58.5, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-15T10:00:00+00:00', + 'precipitation': 0.06, + 'precipitation_probability': 55, + 'temperature': 43.7, + 'templow': 29.4, + 'wind_bearing': 84.47, + 'wind_speed': 57.2, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-16T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 43.0, + 'templow': 29.1, + 'wind_bearing': 103.85, + 'wind_speed': 24.16, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-17T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 52.4, + 'templow': 34.3, + 'wind_bearing': 145.41, + 'wind_speed': 26.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-18T10:00:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 10, + 'temperature': 54.1, + 'templow': 41.3, + 'wind_bearing': 62.99, + 'wind_speed': 23.69, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2021-03-19T10:00:00+00:00', + 'precipitation': 0.12, + 'precipitation_probability': 55, + 'temperature': 48.9, + 'templow': 39.4, + 'wind_bearing': 68.54, + 'wind_speed': 50.08, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2021-03-20T10:00:00+00:00', + 'precipitation': 0.05, + 'precipitation_probability': 33, + 'temperature': 40.1, + 'templow': 35.1, + 'wind_bearing': 56.98, + 'wind_speed': 62.46, + }), + ]), + }) +# --- +# name: test_v4_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T17:48:00+00:00', + 'dew_point': 12.8, + 'humidity': 58, + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.1, + 'wind_bearing': 315.14, + 'wind_speed': 33.59, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T18:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.8, + 'wind_bearing': 321.71, + 'wind_speed': 31.82, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T19:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.8, + 'wind_bearing': 323.38, + 'wind_speed': 32.04, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T20:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 45.3, + 'wind_bearing': 318.43, + 'wind_speed': 33.73, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T21:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 44.6, + 'wind_bearing': 320.9, + 'wind_speed': 28.98, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T22:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 41.9, + 'wind_bearing': 322.11, + 'wind_speed': 15.7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-07T23:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 38.9, + 'wind_bearing': 295.94, + 'wind_speed': 17.78, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T00:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 36.2, + 'wind_bearing': 11.94, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2021-03-08T01:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 34.3, + 'wind_bearing': 13.68, + 'wind_speed': 20.05, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T02:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 32.9, + 'wind_bearing': 14.93, + 'wind_speed': 19.48, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T03:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.9, + 'wind_bearing': 26.07, + 'wind_speed': 16.6, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T04:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 51.27, + 'wind_speed': 9.32, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T05:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.4, + 'wind_bearing': 343.25, + 'wind_speed': 11.92, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T06:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.7, + 'wind_bearing': 341.46, + 'wind_speed': 15.37, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T07:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.4, + 'wind_bearing': 322.34, + 'wind_speed': 12.71, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T08:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 26.1, + 'wind_bearing': 294.69, + 'wind_speed': 13.14, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T09:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 30.1, + 'wind_bearing': 325.32, + 'wind_speed': 11.52, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T10:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 31.0, + 'wind_bearing': 322.27, + 'wind_speed': 10.22, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T11:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 27.2, + 'wind_bearing': 310.14, + 'wind_speed': 20.12, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2021-03-08T12:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 29.2, + 'wind_bearing': 324.8, + 'wind_speed': 25.38, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T13:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 33.2, + 'wind_bearing': 335.16, + 'wind_speed': 23.26, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T14:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 37.0, + 'wind_bearing': 324.49, + 'wind_speed': 21.17, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2021-03-08T15:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 40.0, + 'wind_bearing': 310.68, + 'wind_speed': 19.98, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2021-03-08T16:48:00+00:00', + 'precipitation': 0.0, + 'precipitation_probability': 0, + 'temperature': 42.4, + 'wind_bearing': 304.18, + 'wind_speed': 19.66, + }), + ]), + }) +# --- diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index 5fd954859b1..fe17bbe79b7 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -1,6 +1,7 @@ """Tests for Tomorrow.io init.""" from datetime import timedelta -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, @@ -11,7 +12,6 @@ from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from .const import MIN_CONFIG @@ -42,10 +42,9 @@ async def test_load_and_unload(hass: HomeAssistant) -> None: async def test_update_intervals( - hass: HomeAssistant, tomorrowio_config_entry_update + hass: HomeAssistant, freezer: FrozenDateTimeFactory, tomorrowio_config_entry_update ) -> None: """Test coordinator update intervals.""" - now = dt_util.utcnow() data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) data[CONF_NAME] = "test" config_entry = MockConfigEntry( @@ -56,63 +55,58 @@ async def test_update_intervals( version=1, ) config_entry.add_to_hass(hass) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=now): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 1 + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 tomorrowio_config_entry_update.reset_mock() # Before the update interval, no updates yet - future = now + timedelta(minutes=30) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 0 + freezer.tick(timedelta(minutes=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 tomorrowio_config_entry_update.reset_mock() # On the update interval, we get a new update - future = now + timedelta(minutes=32) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, now + timedelta(minutes=32)) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 1 + freezer.tick(timedelta(minutes=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 - tomorrowio_config_entry_update.reset_mock() + tomorrowio_config_entry_update.reset_mock() - # Adding a second config entry should cause the update interval to double - config_entry_2 = MockConfigEntry( - domain=DOMAIN, - data=data, - options={CONF_TIMESTEP: 1}, - unique_id=f"{_get_unique_id(hass, data)}_1", - version=1, - ) - config_entry_2.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry_2.entry_id) - await hass.async_block_till_done() - assert config_entry.data[CONF_API_KEY] == config_entry_2.data[CONF_API_KEY] - # We should get an immediate call once the new config entry is setup for a - # partial update - assert len(tomorrowio_config_entry_update.call_args_list) == 1 + # Adding a second config entry should cause the update interval to double + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: 1}, + unique_id=f"{_get_unique_id(hass, data)}_1", + version=1, + ) + config_entry_2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry.data[CONF_API_KEY] == config_entry_2.data[CONF_API_KEY] + # We should get an immediate call once the new config entry is setup for a + # partial update + assert len(tomorrowio_config_entry_update.call_args_list) == 1 tomorrowio_config_entry_update.reset_mock() # We should get no new calls on our old interval - future = now + timedelta(minutes=64) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 0 + freezer.tick(timedelta(minutes=32)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 tomorrowio_config_entry_update.reset_mock() # We should get two calls on our new interval, one for each entry - future = now + timedelta(minutes=96) - with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 2 + freezer.tick(timedelta(minutes=32)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 2 tomorrowio_config_entry_update.reset_mock() diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 586fd87f681..a6a5e935614 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -1,10 +1,13 @@ """Tests for Tomorrow.io weather entity.""" from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, @@ -21,6 +24,8 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_DEW_POINT, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TEMP, @@ -41,16 +46,19 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY, SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_NAME from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util from .const import API_V4_ENTRY_DATA from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator @callback @@ -65,23 +73,47 @@ def _enable_entity(hass: HomeAssistant, entity_name: str) -> None: assert updated_entry.disabled is False +async def _setup_config_entry(hass: HomeAssistant, config: dict[str, Any]) -> State: + """Set up entry and return entity state.""" + data = _get_config_schema(hass, SOURCE_USER)(config) + data[CONF_NAME] = DEFAULT_NAME + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + unique_id=_get_unique_id(hass, data), + 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() + + async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: """Set up entry and return entity state.""" + with freeze_time(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)): + await _setup_config_entry(hass, config) + + return hass.states.get("weather.tomorrow_io_daily") + + +async def _setup_legacy(hass: HomeAssistant, config: dict[str, Any]) -> State: + """Set up entry and return entity state.""" + registry = er.async_get(hass) + data = _get_config_schema(hass, SOURCE_USER)(config) + for entity_name in ("hourly", "nowcast"): + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + f"{_get_unique_id(hass, data)}_{entity_name}", + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + suggested_object_id=f"tomorrow_io_{entity_name}", + ) + with freeze_time( datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC) ) as frozen_time: - data = _get_config_schema(hass, SOURCE_USER)(config) - data[CONF_NAME] = DEFAULT_NAME - config_entry = MockConfigEntry( - domain=DOMAIN, - data=data, - options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, - unique_id=_get_unique_id(hass, data), - 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() + await _setup_config_entry(hass, config) for entity_name in ("hourly", "nowcast"): _enable_entity(hass, f"weather.tomorrow_io_{entity_name}") await hass.async_block_till_done() @@ -94,6 +126,33 @@ async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: return hass.states.get("weather.tomorrow_io_daily") +async def test_new_config_entry(hass: HomeAssistant) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + await _setup(hass, API_V4_ENTRY_DATA) + assert len(hass.states.async_entity_ids("weather")) == 1 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 28 + + +async def test_legacy_config_entry(hass: HomeAssistant) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + data = _get_config_schema(hass, SOURCE_USER)(API_V4_ENTRY_DATA) + for entity_name in ("hourly", "nowcast"): + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + f"{_get_unique_id(hass, data)}_{entity_name}", + ) + await _setup(hass, API_V4_ENTRY_DATA) + assert len(hass.states.async_entity_ids("weather")) == 3 + + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 30 + + async def test_v4_weather(hass: HomeAssistant) -> None: """Test v4 weather data.""" weather_state = await _setup(hass, API_V4_ENTRY_DATA) @@ -107,6 +166,41 @@ async def test_v4_weather(hass: HomeAssistant) -> None: ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 45.9, ATTR_FORECAST_TEMP_LOW: 26.1, + ATTR_FORECAST_DEW_POINT: 12.8, + ATTR_FORECAST_HUMIDITY: 58, + ATTR_FORECAST_WIND_BEARING: 239.6, + ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h + } + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 + assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 + assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 30.35 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == "hPa" + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 44.1 + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == "°C" + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 8.15 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == "km" + assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 33.59 # 9.33 m/s ->km/h + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" + + +async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: + """Test v4 weather data.""" + weather_state = await _setup_legacy(hass, API_V4_ENTRY_DATA) + assert weather_state.state == ATTR_CONDITION_SUNNY + assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert len(weather_state.attributes[ATTR_FORECAST]) == 14 + assert weather_state.attributes[ATTR_FORECAST][0] == { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_SUNNY, + ATTR_FORECAST_TIME: "2021-03-07T11:00:00+00:00", + ATTR_FORECAST_DEW_POINT: 12.8, + ATTR_FORECAST_HUMIDITY: 58, + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 45.9, + ATTR_FORECAST_TEMP_LOW: 26.1, ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } @@ -123,3 +217,105 @@ async def test_v4_weather(hass: HomeAssistant) -> None: assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 33.59 # 9.33 m/s ->km/h assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" + + +@freeze_time(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) +async def test_v4_forecast_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test multiple forecast.""" + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + + for forecast_type in ("daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + +async def test_v4_bad_forecast( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + tomorrowio_config_entry_update, + snapshot: SnapshotAssertion, +) -> None: + """Test bad forecast data.""" + freezer.move_to(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) + + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + hourly_forecast = tomorrowio_config_entry_update.return_value["forecasts"]["hourly"] + hourly_forecast[0]["values"]["precipitationProbability"] = "blah" + + # Trigger data refetch + freezer.tick(timedelta(minutes=32) + timedelta(seconds=1)) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity_id, + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"][0]["precipitation_probability"] is None + + +@pytest.mark.parametrize("forecast_type", ["daily", "hourly"]) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + forecast_type: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + freezer.move_to(datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC)) + + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + entity_id = weather_state.entity_id + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(timedelta(minutes=32) + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot diff --git a/tests/components/tplink/test_diagnostics.py b/tests/components/tplink/test_diagnostics.py index 5b3fe4803a4..3ef42c48b2f 100644 --- a/tests/components/tplink/test_diagnostics.py +++ b/tests/components/tplink/test_diagnostics.py @@ -14,17 +14,19 @@ from tests.typing import ClientSessionGenerator @pytest.mark.parametrize( - ("mocked_dev", "fixture_file", "sysinfo_vars"), + ("mocked_dev", "fixture_file", "sysinfo_vars", "expected_oui"), [ ( _mocked_bulb(), "tplink-diagnostics-data-bulb-kl130.json", ["mic_mac", "deviceId", "oemId", "hwId", "alias"], + "AA:BB:CC", ), ( _mocked_plug(), "tplink-diagnostics-data-plug-hs110.json", ["mac", "deviceId", "oemId", "hwId", "alias", "longitude_i", "latitude_i"], + "AA:BB:CC", ), ], ) @@ -34,6 +36,7 @@ async def test_diagnostics( mocked_dev: SmartDevice, fixture_file: str, sysinfo_vars: list[str], + expected_oui: str | None, ): """Test diagnostics for config entry.""" diagnostics_data = json.loads(load_fixture(fixture_file, "tplink")) @@ -58,3 +61,5 @@ async def test_diagnostics( sysinfo = last_response["system"]["get_sysinfo"] for var in sysinfo_vars: assert sysinfo[var] == "**REDACTED**" + + assert result["oui"] == expected_oui diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 0354549d451..4206c0de6ad 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -4,6 +4,8 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import MagicMock, patch +import pytest + from homeassistant import setup from homeassistant.components import tplink from homeassistant.components.tplink.const import DOMAIN @@ -111,3 +113,23 @@ async def test_dimmer_switch_unique_id_fix_original_entity_still_exists( ) assert migrated_dimmer_entity_reg.entity_id == original_dimmer_entity_reg.entity_id assert migrated_dimmer_entity_reg.entity_id != rollout_dimmer_entity_reg.entity_id + + +async def test_config_entry_wrong_mac_Address( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test config entry enters setup retry when mac address mismatches.""" + mismatched_mac = f"{MAC_ADDRESS[:-1]}0" + already_migrated_config_entry = MockConfigEntry( + 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(): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + + assert ( + "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 + ) diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py new file mode 100644 index 00000000000..8f977de588c --- /dev/null +++ b/tests/components/tplink_omada/conftest.py @@ -0,0 +1,87 @@ +"""Test fixtures for TP-Link Omada integration.""" +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails + +from homeassistant.components.tplink_omada.config_flow import CONF_SITE +from homeassistant.components.tplink_omada.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +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="Test Omada Controller", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "mocked-password", + CONF_USERNAME: "mocked-user", + CONF_VERIFY_SSL: False, + CONF_SITE: "Default", + }, + unique_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tplink_omada.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_omada_site_client() -> Generator[AsyncMock, None, None]: + """Mock Omada site client.""" + site_client = AsyncMock() + switch1_data = json.loads(load_fixture("switch-TL-SG3210XHP-M2.json", DOMAIN)) + switch1 = OmadaSwitch(switch1_data) + site_client.get_switches.return_value = [switch1] + + switch1_ports_data = json.loads( + load_fixture("switch-ports-TL-SG3210XHP-M2.json", DOMAIN) + ) + switch1_ports = [OmadaSwitchPortDetails(p) for p in switch1_ports_data] + site_client.get_switch_ports.return_value = switch1_ports + + return site_client + + +@pytest.fixture +def mock_omada_client( + mock_omada_site_client: AsyncMock, +) -> Generator[MagicMock, None, None]: + """Mock Omada client.""" + with patch( + "homeassistant.components.tplink_omada.create_omada_client", + autospec=True, + ) as client_mock: + client = client_mock.return_value + + client.get_site_client.return_value = mock_omada_site_client + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, +) -> MockConfigEntry: + """Set up the TP-Link Omada 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/tplink_omada/fixtures/switch-TL-SG3210XHP-M2.json b/tests/components/tplink_omada/fixtures/switch-TL-SG3210XHP-M2.json new file mode 100644 index 00000000000..2e3f21406b0 --- /dev/null +++ b/tests/components/tplink_omada/fixtures/switch-TL-SG3210XHP-M2.json @@ -0,0 +1,683 @@ +{ + "type": "switch", + "mac": "54-AF-97-00-00-01", + "name": "Test PoE Switch", + "model": "TL-SG3210XHP-M2", + "modelVersion": "1.0", + "compoundModel": "TL-SG3210XHP-M2 v1.0", + "showModel": "TL-SG3210XHP-M2 v1.0", + "firmwareVersion": "1.0.12 Build 20230203 Rel.36545", + "version": "1.0.12", + "hwVersion": "1.0", + "status": 14, + "statusCategory": 1, + "site": "000000000000000000000000", + "omadacId": "00000000000000000000000000000000", + "compatible": 0, + "sn": "Y220000000001", + "addedInAdvanced": false, + "deviceMisc": { + "portNum": 10 + }, + "devCap": { + "maxMirrorGroup": 1, + "maxMirroredPort": 9, + "maxLagNum": 8, + "maxLagMember": 8, + "poePortNum": 8, + "poeSupport": true, + "supportBt": false, + "jumboSupport": true, + "jumboOddSupport": false, + "lagCap": { + "lacpModSupport": true, + "lagHashAlgSupport": true, + "lagHashAlgs": [0, 1, 2, 3, 4, 5] + }, + "eeeSupport": true, + "flowControlSupport": true, + "loopbackVlanBasedSupport": true, + "dhcpL2RelaySupport": true, + "sfpBeginNum": 9, + "sfpNum": 2 + }, + "ledSetting": 2, + "mvlanNetworkId": "000000000000000000000000", + "ipSetting": { + "mode": "dhcp", + "fallback": true, + "fallbackIp": "192.168.0.1", + "fallbackMask": "255.255.255.0" + }, + "loopbackDetectEnable": true, + "stp": 0, + "priority": 32768, + "snmp": { + "location": "", + "contact": "" + }, + "ports": [ + { + "id": "000000000000000000000001", + "port": 1, + "name": "Port1", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 1, + "linkStatus": 1, + "linkSpeed": 2, + "duplex": 2, + "poe": true, + "poePower": 2.7, + "tx": 22048870335, + "rx": 6155774646, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000002", + "port": 2, + "name": "Port2", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 2, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 2111818481511, + "rx": 297809855535, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000003", + "port": 3, + "name": "Primary AP", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 3, + "linkStatus": 1, + "linkSpeed": 4, + "duplex": 2, + "poe": true, + "poePower": 9.8, + "tx": 2118915311852, + "rx": 1222744181939, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000004", + "port": 4, + "name": "Port4", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 4, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000005", + "port": 5, + "name": "Port5", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 5, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 7.2, + "tx": 357059477760, + "rx": 59530432926, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000006", + "port": 6, + "name": "Port6", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 6, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 20729276425, + "rx": 1260359882, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000007", + "port": 7, + "name": "Port7", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 7, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 6884938116575, + "rx": 3015211000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000008", + "port": 8, + "name": "Family Room Kiosk", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 8, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 1.9, + "tx": 17735212467, + "rx": 2751725454, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ] + }, + { + "id": "000000000000000000000009", + "port": 9, + "name": "Port9", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 9, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ] + }, + { + "id": "00000000000000000000000a", + "port": 10, + "name": "Uplink", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "operation": "switching", + "portStatus": { + "port": 10, + "linkStatus": 1, + "linkSpeed": 5, + "duplex": 2, + "poe": false, + "tx": 4599788287992, + "rx": 11431810000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ] + } + ], + "lags": [], + "tagIds": [], + "ip": "192.168.0.12", + "lastSeen": 1687385981898, + "needUpgrade": false, + "uptime": "97day(s) 23h 57m 34s", + "uptimeLong": 8467054, + "cpuUtil": 18, + "memUtil": 72, + "poeRemain": 218.399994, + "poeRemainPercent": 91.0, + "fanStatus": 0, + "downlinkList": [ + { + "port": 3, + "model": "EAP660 HD", + "hwVersion": "1.0", + "modelVersion": "1.0", + "mac": "B4-B0-24-00-00-01", + "name": "Access Point 1", + "linkSpeed": 4, + "duplex": 2 + }, + { + "port": 5, + "model": "EAP653", + "hwVersion": "1.0", + "modelVersion": "1.0", + "mac": "34-60-F9-00-00-01E", + "name": "Access Point 2", + "linkSpeed": 3, + "duplex": 2 + } + ], + "download": 16037273330382, + "upload": 16133033034917, + "supportVlanIf": true, + "jumbo": 1518, + "lagHashAlg": 2, + "speeds": [2, 3, 4, 5] +} diff --git a/tests/components/tplink_omada/fixtures/switch-ports-TL-SG3210XHP-M2.json b/tests/components/tplink_omada/fixtures/switch-ports-TL-SG3210XHP-M2.json new file mode 100644 index 00000000000..b079b2d2fb7 --- /dev/null +++ b/tests/components/tplink_omada/fixtures/switch-ports-TL-SG3210XHP-M2.json @@ -0,0 +1,974 @@ +[ + { + "id": "000000000000000000000001", + "port": 1, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port1", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 1, + "linkStatus": 1, + "linkSpeed": 2, + "duplex": 2, + "poe": true, + "poePower": 2.7, + "tx": 22265663705, + "rx": 6202420396, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000002", + "port": 2, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port2", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": true, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 1, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 2, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 2136778000000, + "rx": 298419647322, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000003", + "port": 3, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port3", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 3, + "linkStatus": 1, + "linkSpeed": 4, + "duplex": 2, + "poe": true, + "poePower": 10.0, + "tx": 2139129000000, + "rx": 1241262105432, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000004", + "port": 4, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port4", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 4, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000005", + "port": 5, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port5", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 5, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 7.7, + "tx": 358431854723, + "rx": 62202058965, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000006", + "port": 6, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port6", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 6, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": false, + "poePower": 0.0, + "tx": 21045680895, + "rx": 1266702649, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000007", + "port": 7, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port7", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 7, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "poePower": 0.0, + "tx": 6884938116575, + "rx": 3015211000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000008", + "port": 8, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port8", + "disable": false, + "type": 1, + "maxSpeed": 4, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 0, + "duplex": 0, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": false, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 8, + "linkStatus": 1, + "linkSpeed": 3, + "duplex": 2, + "poe": true, + "poePower": 1.9, + "tx": 17983115259, + "rx": 2764463784, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 2, + "duplex": 1 + }, + { + "linkSpeed": 2, + "duplex": 2 + }, + { + "linkSpeed": 4, + "duplex": 0 + }, + { + "linkSpeed": 4, + "duplex": 2 + }, + { + "linkSpeed": 0, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 1 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 0, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 2 + }, + { + "linkSpeed": 2, + "duplex": 0 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "000000000000000000000009", + "port": 9, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port9", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 5, + "duplex": 2, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 9, + "linkStatus": 0, + "linkSpeed": 0, + "duplex": 0, + "poe": false, + "tx": 0, + "rx": 0, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + }, + { + "id": "00000000000000000000000a", + "port": 10, + "switchId": "640934810000000000000000", + "switchMac": "54-AF-97-00-00-01", + "site": "000000000000000000000000", + "name": "Port10", + "disable": false, + "type": 3, + "maxSpeed": 5, + "profileId": "00000000", + "profileName": "All", + "profileOverrideEnable": false, + "operation": "switching", + "linkSpeed": 5, + "duplex": 2, + "dot1x": 2, + "poe": 1, + "bandWidthCtrlType": 0, + "bandCtrl": { + "egressEnable": false, + "egressLimit": 0, + "egressUnit": 1, + "ingressEnable": false, + "ingressLimit": 0, + "ingressUnit": 1 + }, + "stormCtrl": { + "unknownUnicastEnable": false, + "unknownUnicast": 0, + "multicastEnable": false, + "multicast": 0, + "broadcastEnable": false, + "broadcast": 0, + "action": 0, + "recoverTime": 3600 + }, + "lldpMedEnable": true, + "topoNotifyEnable": false, + "spanningTreeEnable": false, + "loopbackDetectEnable": true, + "loopbackDetectVlanBasedEnable": false, + "portIsolationEnable": false, + "portStatus": { + "port": 10, + "linkStatus": 1, + "linkSpeed": 5, + "duplex": 2, + "poe": false, + "tx": 4621489812572, + "rx": 11477190000000, + "stpDiscarding": false + }, + "portCap": [ + { + "linkSpeed": 5, + "duplex": 2 + }, + { + "linkSpeed": 3, + "duplex": 0 + }, + { + "linkSpeed": 3, + "duplex": 2 + } + ], + "eeeEnable": false, + "flowControlEnable": false, + "dhcpL2RelaySettings": { + "enable": false, + "format": 0 + } + } +] diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b48f6a5e749 --- /dev/null +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -0,0 +1,345 @@ +# serializer version: 1 +# name: test_poe_switches + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 1 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_1_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.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.test_poe_switch_port_1_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 1 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000001_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 6 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_6_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.11 + 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.test_poe_switch_port_6_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 6 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000006_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.12 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 7 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_7_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.13 + 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.test_poe_switch_port_7_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 7 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000007_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.14 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 8 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_8_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.15 + 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.test_poe_switch_port_8_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 8 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000008_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 2 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_2_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.3 + 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.test_poe_switch_port_2_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 2 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000002_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 3 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_3_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.5 + 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.test_poe_switch_port_3_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 3 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000003_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 4 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_4_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.7 + 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.test_poe_switch_port_4_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 4 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000004_poe', + 'unit_of_measurement': None, + }) +# --- +# name: test_poe_switches.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test PoE Switch Port 5 PoE', + 'icon': 'mdi:ethernet', + }), + 'context': , + 'entity_id': 'switch.test_poe_switch_port_5_poe', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_poe_switches.9 + 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.test_poe_switch_port_5_poe', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ethernet', + 'original_name': 'Port 5 PoE', + 'platform': 'tplink_omada', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '54-AF-97-00-00-01_000000000000000000000005_poe', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/tplink_omada/test_switch.py b/tests/components/tplink_omada/test_switch.py new file mode 100644 index 00000000000..dd8b520e0a8 --- /dev/null +++ b/tests/components/tplink_omada/test_switch.py @@ -0,0 +1,122 @@ +"""Tests for TP-Link Omada switch entities.""" +from unittest.mock import MagicMock + +from syrupy.assertion import SnapshotAssertion +from tplink_omada_client.definitions import PoEMode +from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from tplink_omada_client.omadasiteclient import SwitchPortOverrides + +from homeassistant.components import switch +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_poe_switches( + hass: HomeAssistant, + mock_omada_site_client: MagicMock, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test PoE switch.""" + poe_switch_mac = "54-AF-97-00-00-01" + for i in range(1, 9): + await _test_poe_switch( + hass, + mock_omada_site_client, + f"switch.test_poe_switch_port_{i}_poe", + poe_switch_mac, + i, + snapshot, + ) + + +async def _test_poe_switch( + hass: HomeAssistant, + mock_omada_site_client: MagicMock, + entity_id: str, + network_switch_mac: str, + port_num: int, + snapshot: SnapshotAssertion, +) -> None: + entity_registry = er.async_get(hass) + + def assert_update_switch_port( + device: OmadaSwitch, + switch_port_details: OmadaSwitchPortDetails, + poe_enabled: bool, + overrides: SwitchPortOverrides = None, + ) -> None: + assert device + assert device.mac == network_switch_mac + assert switch_port_details + assert switch_port_details.port == port_num + assert overrides + assert overrides.enable_poe == poe_enabled + + entity = hass.states.get(entity_id) + assert entity == snapshot + entry = entity_registry.async_get(entity_id) + assert entry == snapshot + + mock_omada_site_client.update_switch_port.reset_mock() + mock_omada_site_client.update_switch_port.return_value = await _update_port_details( + mock_omada_site_client, port_num, False + ) + await call_service(hass, "turn_off", entity_id) + mock_omada_site_client.update_switch_port.assert_called_once() + ( + device, + switch_port_details, + ) = mock_omada_site_client.update_switch_port.call_args.args + assert_update_switch_port( + device, + switch_port_details, + False, + **mock_omada_site_client.update_switch_port.call_args.kwargs, + ) + entity = hass.states.get(entity_id) + assert entity.state == "off" + + mock_omada_site_client.update_switch_port.reset_mock() + mock_omada_site_client.update_switch_port.return_value = await _update_port_details( + mock_omada_site_client, port_num, True + ) + await call_service(hass, "turn_on", entity_id) + mock_omada_site_client.update_switch_port.assert_called_once() + device, switch_port = mock_omada_site_client.update_switch_port.call_args.args + assert_update_switch_port( + device, + switch_port, + True, + **mock_omada_site_client.update_switch_port.call_args.kwargs, + ) + entity = hass.states.get(entity_id) + assert entity.state == "on" + + +async def _update_port_details( + mock_omada_site_client: MagicMock, + port_num: int, + poe_enabled: bool, +) -> OmadaSwitchPortDetails: + switch_ports = await mock_omada_site_client.get_switch_ports() + port_details: OmadaSwitchPortDetails = None + for details in switch_ports: + if details.port == port_num: + port_details = details + break + + assert port_details is not None + raw_data = port_details.raw_data.copy() + raw_data["poe"] = PoEMode.ENABLED if poe_enabled else PoEMode.DISABLED + return OmadaSwitchPortDetails(raw_data) + + +def call_service(hass: HomeAssistant, service: str, entity_id: str): + """Call any service on entity.""" + return hass.services.async_call( + switch.DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py index 065b459354a..ed6cc3f629b 100644 --- a/tests/components/traccar/test_device_tracker.py +++ b/tests/components/traccar/test_device_tracker.py @@ -1,5 +1,4 @@ """The tests for the Traccar device tracker platform.""" -from datetime import datetime from unittest.mock import AsyncMock, patch from pytraccar import ReportsEventeModel @@ -17,6 +16,7 @@ from homeassistant.const import ( ) 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 @@ -47,7 +47,7 @@ async def test_import_events_catch_all(hass: HomeAssistant) -> None: "maintenanceId": 1, "deviceId": device["id"], "type": "ignitionOn", - "eventTime": datetime.utcnow().isoformat(), + "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), "attributes": {}, } ), @@ -59,7 +59,7 @@ async def test_import_events_catch_all(hass: HomeAssistant) -> None: "maintenanceId": 1, "deviceId": device["id"], "type": "ignitionOff", - "eventTime": datetime.utcnow().isoformat(), + "eventTime": dt_util.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), "attributes": {}, } ), diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py new file mode 100644 index 00000000000..026c122fb57 --- /dev/null +++ b/tests/components/trafikverket_camera/__init__.py @@ -0,0 +1,10 @@ +"""Tests for the Trafikverket Camera integration.""" +from __future__ import annotations + +from homeassistant.components.trafikverket_camera.const import CONF_LOCATION +from homeassistant.const import CONF_API_KEY + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", +} diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py new file mode 100644 index 00000000000..fc6d70ae704 --- /dev/null +++ b/tests/components/trafikverket_camera/conftest.py @@ -0,0 +1,69 @@ +"""Fixtures for Trafikverket Camera integration tests.""" +from __future__ import annotations + +from datetime import datetime +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(name="load_int") +async def load_integration_from_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, get_camera: CameraInfo +) -> MockConfigEntry: + """Set up the Trafikverket Camera integration in Home Assistant.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_camera") +def fixture_get_camera() -> CameraInfo: + """Construct Camera Mock.""" + + 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", + ) diff --git a/tests/components/trafikverket_camera/test_camera.py b/tests/components/trafikverket_camera/test_camera.py new file mode 100644 index 00000000000..b3df7cfcdcb --- /dev/null +++ b/tests/components/trafikverket_camera/test_camera.py @@ -0,0 +1,72 @@ +"""The test for the Trafikverket camera platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.components.camera import async_get_image +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_camera( + hass: HomeAssistant, + load_int: ConfigEntry, + freezer: FrozenDateTimeFactory, + monkeypatch: pytest.MonkeyPatch, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera sensor.""" + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + assert state1.attributes["description"] == "Test Camera for testing" + assert state1.attributes["location"] == "Test location" + assert state1.attributes["type"] == "Road" + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", + content=b"9876543210", + ) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + assert state1.attributes != {} + + assert await async_get_image(hass, "camera.test_location") + + monkeypatch.setattr( + get_camera, + "photourl", + None, + ) + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", + status=404, + ) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await async_get_image(hass, "camera.test_location") diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py new file mode 100644 index 00000000000..38c49d54208 --- /dev/null +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -0,0 +1,234 @@ +"""Test the Trafikverket Camera config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) + +from homeassistant import config_entries +from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> 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.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Test location" + assert result2["data"] == { + "api_key": "1234567890", + "location": "Test location", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == "trafikverket_camera-Test location" + + +@pytest.mark.parametrize( + ("side_effect", "error_key", "base_error"), + [ + ( + InvalidAuthentication, + "base", + "invalid_auth", + ), + ( + NoCameraFound, + "location", + "invalid_location", + ), + ( + MultipleCamerasFound, + "location", + "more_locations", + ), + ( + UnknownError, + "base", + "cannot_connect", + ), + ], +) +async def test_flow_fails( + hass: HomeAssistant, side_effect: Exception, error_key: str, base_error: str +) -> None: + """Test config flow errors.""" + result4 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result4["type"] == FlowResultType.FORM + assert result4["step_id"] == config_entries.SOURCE_USER + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + side_effect=side_effect, + ): + result4 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input={ + CONF_API_KEY: "1234567890", + CONF_LOCATION: "incorrect", + }, + ) + + assert result4["errors"] == {error_key: base_error} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", + }, + unique_id="1234", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "api_key": "1234567891", + "location": "Test location", + } + + +@pytest.mark.parametrize( + ("side_effect", "error_key", "p_error"), + [ + ( + InvalidAuthentication, + "base", + "invalid_auth", + ), + ( + NoCameraFound, + "location", + "invalid_location", + ), + ( + MultipleCamerasFound, + "location", + "more_locations", + ), + ( + UnknownError, + "base", + "cannot_connect", + ), + ], +) +async def test_reauth_flow_error( + hass: HomeAssistant, side_effect: Exception, error_key: str, p_error: str +) -> None: + """Test a reauthentication flow with error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test location", + }, + unique_id="1234", + ) + entry.add_to_hass(hass) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {error_key: p_error} + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "api_key": "1234567891", + "location": "Test location", + } diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py new file mode 100644 index 00000000000..2b21ce935b2 --- /dev/null +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -0,0 +1,151 @@ +"""The test for the Trafikverket Camera coordinator.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleCamerasFound, + NoCameraFound, + UnknownError, +) + +from homeassistant import config_entries +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.components.trafikverket_camera.coordinator import CameraData +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import UpdateFailed + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_coordinator( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraData, +) -> None: + """Test the Trafikverket Camera coordinator.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + + +@pytest.mark.parametrize( + ("sideeffect", "p_error", "entry_state"), + [ + ( + InvalidAuthentication, + ConfigEntryAuthFailed, + config_entries.ConfigEntryState.SETUP_ERROR, + ), + ( + NoCameraFound, + UpdateFailed, + config_entries.ConfigEntryState.SETUP_RETRY, + ), + ( + MultipleCamerasFound, + UpdateFailed, + config_entries.ConfigEntryState.SETUP_RETRY, + ), + ( + UnknownError, + UpdateFailed, + config_entries.ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_coordinator_failed_update( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraData, + sideeffect: str, + p_error: Exception, + entry_state: str, +) -> None: + """Test the Trafikverket Camera coordinator.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + side_effect=sideeffect, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state = hass.states.get("camera.test_location") + assert state is None + assert entry.state == entry_state + + +async def test_coordinator_failed_get_image( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraData, +) -> None: + """Test the Trafikverket Camera coordinator.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", status=404 + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state = hass.states.get("camera.test_location") + assert state is None + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py new file mode 100644 index 00000000000..d9de0a830a6 --- /dev/null +++ b/tests/components/trafikverket_camera/test_init.py @@ -0,0 +1,80 @@ +"""Test for Trafikverket Ferry component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant import config_entries +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_entry( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup entry.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert len(mock_tvt_camera.mock_calls) == 1 + + +async def test_unload_entry( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test unload an entry.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="321", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py new file mode 100644 index 00000000000..021433b33e7 --- /dev/null +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -0,0 +1,46 @@ +"""The tests for Trafikcerket Camera recorder.""" +from __future__ import annotations + +import pytest +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_exclude_attributes( + recorder_mock: Recorder, + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + aioclient_mock: AiohttpClientMocker, + get_camera: CameraInfo, +) -> None: + """Test camera has description and location excluded from recording.""" + state1 = hass.states.get("camera.test_location") + assert state1.state == "idle" + assert state1.attributes["description"] == "Test Camera for testing" + assert state1.attributes["location"] == "Test location" + assert state1.attributes["type"] == "Road" + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, + hass, + dt_util.now(), + None, + hass.states.async_entity_ids(), + ) + assert len(states) == 1 + assert states.get("camera.test_location") + for entity_states in states.values(): + for state in entity_states: + assert "location" not in state.attributes + assert "description" not in state.attributes + assert "type" in state.attributes diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py index 591486474d3..c0fbe7537cc 100644 --- a/tests/components/trafikverket_ferry/test_coordinator.py +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -24,6 +24,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_coordinator( hass: HomeAssistant, entity_registry_enabled_by_default: None, + freezer: FrozenDateTimeFactory, monkeypatch: pytest.MonkeyPatch, get_ferries: list[FerryStop], ) -> None: @@ -59,7 +60,8 @@ async def test_coordinator( datetime(dt_util.now().year + 2, 5, 1, 12, 0, tzinfo=dt_util.UTC), ) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -71,7 +73,8 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = NoFerryFound() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -80,7 +83,8 @@ async def test_coordinator( mock_data.return_value = get_ferries mock_data.side_effect = None - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() # mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -88,7 +92,8 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = InvalidAuthentication() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 424e1d74162..3493e031669 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -6,8 +6,11 @@ from unittest.mock import patch import pytest from pytrafikverket.exceptions import ( InvalidAuthentication, + MultipleTrainAnnouncementFound, MultipleTrainStationsFound, + NoTrainAnnouncementFound, NoTrainStationFound, + UnknownError, ) from homeassistant import config_entries @@ -35,11 +38,13 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_KEY: "1234567890", @@ -51,9 +56,9 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Stockholm C to Uppsala C at 10:00" - assert result2["data"] == { + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Stockholm C to Uppsala C at 10:00" + assert result["data"] == { "api_key": "1234567890", "name": "Stockholm C to Uppsala C at 10:00", "from": "Stockholm C", @@ -61,8 +66,9 @@ async def test_form(hass: HomeAssistant) -> None: "time": "10:00", "weekday": ["mon", "fri"], } + assert result["options"] == {"filter_product": None} assert len(mock_setup_entry.mock_calls) == 1 - assert result2["result"].unique_id == "{}-{}-{}-{}".format( + assert result["result"].unique_id == "{}-{}-{}-{}".format( "stockholmc", "uppsalac", "10:00", "['mon', 'fri']" ) @@ -92,11 +98,13 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_API_KEY: "1234567890", @@ -108,8 +116,8 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.ABORT - assert result2["reason"] == "already_configured" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize( @@ -137,19 +145,21 @@ async def test_flow_fails( hass: HomeAssistant, side_effect: Exception, base_error: str ) -> None: """Test config flow errors.""" - result4 = await hass.config_entries.flow.async_init( + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result4["type"] == FlowResultType.FORM - assert result4["step_id"] == config_entries.SOURCE_USER + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", side_effect=side_effect(), + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ): - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ CONF_API_KEY: "1234567890", CONF_FROM: "Stockholm C", @@ -157,32 +167,55 @@ async def test_flow_fails( }, ) - assert result4["errors"] == {"base": base_error} + assert result["errors"] == {"base": base_error} -async def test_flow_fails_incorrect_time(hass: HomeAssistant) -> None: - """Test config flow errors due to bad time.""" - result5 = await hass.config_entries.flow.async_init( +@pytest.mark.parametrize( + ("side_effect", "base_error"), + [ + ( + NoTrainAnnouncementFound, + "no_trains", + ), + ( + MultipleTrainAnnouncementFound, + "multiple_trains", + ), + ( + UnknownError, + "cannot_connect", + ), + ], +) +async def test_flow_fails_departures( + hass: HomeAssistant, side_effect: Exception, base_error: str +) -> None: + """Test config flow errors.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result5["type"] == FlowResultType.FORM - assert result5["step_id"] == config_entries.SOURCE_USER + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stop", + side_effect=side_effect(), + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ): - result6 = await hass.config_entries.flow.async_configure( - result5["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ CONF_API_KEY: "1234567890", CONF_FROM: "Stockholm C", CONF_TO: "Uppsala C", - CONF_TIME: "25:25", }, ) - assert result6["errors"] == {"base": "invalid_time"} + assert result["errors"] == {"base": base_error} async def test_reauth_flow(hass: HomeAssistant) -> None: @@ -216,18 +249,20 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "1234567891"}, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", @@ -290,31 +325,35 @@ async def test_reauth_flow_error( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", side_effect=side_effect(), + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "1234567890"}, ) await hass.async_block_till_done() - assert result2["step_id"] == "reauth_confirm" - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": p_error} + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": p_error} with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", ), patch( "homeassistant.components.trafikverket_train.async_setup_entry", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "1234567891"}, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" assert entry.data == { "api_key": "1234567891", "name": "Stockholm C to Uppsala C at 10:00", @@ -323,3 +362,142 @@ async def test_reauth_flow_error( "time": "10:00", "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], } + + +@pytest.mark.parametrize( + ("side_effect", "p_error"), + [ + ( + NoTrainAnnouncementFound, + "no_trains", + ), + ( + MultipleTrainAnnouncementFound, + "multiple_trains", + ), + ( + UnknownError, + "cannot_connect", + ), + ], +) +async def test_reauth_flow_error_departures( + hass: HomeAssistant, side_effect: Exception, p_error: str +) -> None: + """Test a reauthentication flow with error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + side_effect=side_effect(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567890"}, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", + ), patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "1234567891"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == { + "api_key": "1234567891", + "name": "Stockholm C to Uppsala C at 10:00", + "from": "Stockholm C", + "to": "Uppsala C", + "time": "10:00", + "weekday": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], + } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": "SJ Regionaltåg"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": "SJ Regionaltåg"} + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": ""}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": None} diff --git a/tests/components/transport_nsw/test_sensor.py b/tests/components/transport_nsw/test_sensor.py index 46aee182b53..5ec28c72fed 100644 --- a/tests/components/transport_nsw/test_sensor.py +++ b/tests/components/transport_nsw/test_sensor.py @@ -1,10 +1,7 @@ """The tests for the Transport NSW (AU) sensor platform.""" from unittest.mock import patch -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorStateClass, -) +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 19574a9ab42..ca0c855d1ab 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -59,6 +59,7 @@ def mock_device_registry(hass): """Mock device registry.""" dev_reg = dr.async_get(hass) config_entry = MockConfigEntry(domain="something_else") + config_entry.add_to_hass(hass) for idx, device in enumerate( ( diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py new file mode 100644 index 00000000000..0c6ac38739e --- /dev/null +++ b/tests/components/unifi/test_button.py @@ -0,0 +1,81 @@ +"""UniFi Network button platform tests.""" + +from aiounifi.websocket import WebsocketState + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass +from homeassistant.components.unifi.const import DOMAIN as UNIFI_DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .test_controller import setup_unifi_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_restart_device_button( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Test restarting device button.""" + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + devices_response=[ + { + "board_rev": 3, + "device_id": "mock-id", + "ip": "10.0.0.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "switch", + "state": 1, + "type": "usw", + "version": "4.0.42.10433", + } + ], + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("button.switch_restart") + assert ent_reg_entry.unique_id == "device_restart-00:00:00:00:01:01" + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Validate state object + button = hass.states.get("button.switch_restart") + assert button is not None + assert button.attributes.get(ATTR_DEVICE_CLASS) == ButtonDeviceClass.RESTART + + # Send restart device command + aioclient_mock.clear_requests() + aioclient_mock.post( + f"https://{controller.host}:1234/api/s/{controller.site}/cmd/devmgr", + ) + + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {"entity_id": "button.switch_restart"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "cmd": "restart", + "mac": "00:00:00:00:01:01", + "reboot_type": "soft", + } + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("button.switch_restart").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 5f1b5d33dcd..f4738862aef 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -9,6 +9,7 @@ import aiounifi from aiounifi.websocket import WebsocketState import pytest +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -39,7 +40,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -79,7 +79,24 @@ ENTRY_OPTIONS = {} CONFIGURATION = [] SITE = [{"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"}] -DESCRIPTION = [{"name": "username", "site_name": "site_id", "site_role": "admin"}] + +SYSTEM_INFORMATION = [ + { + "anonymous_controller_id": "24f81231-a456-4c32-abcd-f5612345385f", + "build": "atag_7.4.162_21057", + "console_display_version": "3.1.15", + "hostname": "UDMP", + "name": "UDMP", + "previous_version": "7.4.156", + "timezone": "Europe/Stockholm", + "ubnt_device_type": "UDMPRO", + "udm_version": "3.0.20.9281", + "update_available": False, + "update_downloaded": False, + "uptime": 1196290, + "version": "7.4.162", + } +] def mock_default_unifi_requests( @@ -87,12 +104,13 @@ def mock_default_unifi_requests( host, site_id, sites=None, - description=None, clients_response=None, clients_all_response=None, devices_response=None, dpiapp_response=None, dpigroup_response=None, + port_forward_response=None, + system_information_response=None, wlans_response=None, ): """Mock default UniFi requests responses.""" @@ -110,12 +128,6 @@ def mock_default_unifi_requests( headers={"content-type": CONTENT_TYPE_JSON}, ) - aioclient_mock.get( - f"https://{host}:1234/api/s/{site_id}/self", - json={"data": description or [], "meta": {"rc": "ok"}}, - headers={"content-type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( f"https://{host}:1234/api/s/{site_id}/stat/sta", json={"data": clients_response or [], "meta": {"rc": "ok"}}, @@ -141,6 +153,16 @@ def mock_default_unifi_requests( json={"data": dpigroup_response or [], "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/rest/portforward", + json={"data": port_forward_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"https://{host}:1234/api/s/{site_id}/stat/sysinfo", + json={"data": system_information_response or [], "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) aioclient_mock.get( f"https://{host}:1234/api/s/{site_id}/rest/wlanconf", json={"data": wlans_response or [], "meta": {"rc": "ok"}}, @@ -155,12 +177,13 @@ async def setup_unifi_integration( config=ENTRY_CONFIG, options=ENTRY_OPTIONS, sites=SITE, - site_description=DESCRIPTION, clients_response=None, clients_all_response=None, devices_response=None, dpiapp_response=None, dpigroup_response=None, + port_forward_response=None, + system_information_response=None, wlans_response=None, known_wireless_clients=None, controllers=None, @@ -191,12 +214,13 @@ async def setup_unifi_integration( host=config_entry.data[CONF_HOST], site_id=config_entry.data[CONF_SITE_ID], sites=sites, - description=site_description, clients_response=clients_response, clients_all_response=clients_all_response, devices_response=devices_response, dpiapp_response=dpiapp_response, dpigroup_response=dpigroup_response, + port_forward_response=port_forward_response, + system_information_response=system_information_response, wlans_response=wlans_response, ) @@ -217,20 +241,21 @@ async def test_controller_setup( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", return_value=True, ) as forward_entry_setup: - config_entry = await setup_unifi_integration(hass, aioclient_mock) + config_entry = await setup_unifi_integration( + hass, aioclient_mock, system_information_response=SYSTEM_INFORMATION + ) controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] entry = controller.config_entry assert len(forward_entry_setup.mock_calls) == len(PLATFORMS) - assert forward_entry_setup.mock_calls[0][1] == (entry, TRACKER_DOMAIN) - assert forward_entry_setup.mock_calls[1][1] == (entry, IMAGE_DOMAIN) - assert forward_entry_setup.mock_calls[2][1] == (entry, SENSOR_DOMAIN) - assert forward_entry_setup.mock_calls[3][1] == (entry, SWITCH_DOMAIN) + assert forward_entry_setup.mock_calls[0][1] == (entry, BUTTON_DOMAIN) + assert forward_entry_setup.mock_calls[1][1] == (entry, TRACKER_DOMAIN) + assert forward_entry_setup.mock_calls[2][1] == (entry, IMAGE_DOMAIN) + assert forward_entry_setup.mock_calls[3][1] == (entry, SENSOR_DOMAIN) + assert forward_entry_setup.mock_calls[4][1] == (entry, SWITCH_DOMAIN) assert controller.host == ENTRY_CONFIG[CONF_HOST] - assert controller.site == ENTRY_CONFIG[CONF_SITE_ID] - assert controller.site_name == SITE[0]["desc"] - assert controller.site_role == SITE[0]["role"] + assert controller.is_admin == (SITE[0]["role"] == "admin") assert controller.option_allow_bandwidth_sensors == DEFAULT_ALLOW_BANDWIDTH_SENSORS assert controller.option_allow_uptime_sensors == DEFAULT_ALLOW_UPTIME_SENSORS @@ -241,29 +266,16 @@ async def test_controller_setup( assert controller.option_detection_time == timedelta(seconds=DEFAULT_DETECTION_TIME) assert isinstance(controller.option_ssid_filter, set) - assert controller.mac is None - assert controller.signal_reachable == "unifi-reachable-1" assert controller.signal_options_update == "unifi-options-1" assert controller.signal_heartbeat_missed == "unifi-heartbeat-missed" - -async def test_controller_mac( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that it is possible to identify controller mac.""" - config_entry = await setup_unifi_integration( - hass, aioclient_mock, clients_response=[CONTROLLER_HOST] - ) - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert controller.mac == CONTROLLER_HOST["mac"] - - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_or_create( + device_entry = dr.async_get(hass).async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, controller.mac)}, + identifiers={(UNIFI_DOMAIN, config_entry.unique_id)}, ) - assert device_entry + + assert device_entry.sw_version == "7.4.162" async def test_controller_not_accessible(hass: HomeAssistant) -> None: @@ -389,9 +401,7 @@ async def test_reconnect_mechanism( await setup_unifi_integration(hass, aioclient_mock) aioclient_mock.clear_requests() - aioclient_mock.post( - f"https://{DEFAULT_HOST}:1234/api/login", status=HTTPStatus.BAD_GATEWAY - ) + aioclient_mock.get(f"https://{DEFAULT_HOST}:1234/", status=HTTPStatus.BAD_GATEWAY) mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() @@ -442,9 +452,7 @@ async def test_reconnect_mechanism_exceptions( async def test_get_unifi_controller(hass: HomeAssistant) -> None: """Successful call.""" - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", return_value=True - ): + with patch("aiounifi.Controller.login", return_value=True): assert await get_unifi_controller(hass, ENTRY_CONFIG) @@ -452,9 +460,7 @@ async def test_get_unifi_controller_verify_ssl_false(hass: HomeAssistant) -> Non """Successful call with verify ssl set to false.""" controller_data = dict(ENTRY_CONFIG) controller_data[CONF_VERIFY_SSL] = False - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", return_value=True - ): + with patch("aiounifi.Controller.login", return_value=True): assert await get_unifi_controller(hass, controller_data) @@ -475,7 +481,7 @@ async def test_get_unifi_controller_fails_to_connect( hass: HomeAssistant, side_effect, raised_exception ) -> None: """Check that get_unifi_controller can handle controller being unavailable.""" - with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch( - "aiounifi.Controller.login", side_effect=side_effect - ), pytest.raises(raised_exception): + with patch("aiounifi.Controller.login", side_effect=side_effect), pytest.raises( + raised_exception + ): await get_unifi_controller(hass, ENTRY_CONFIG) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 16432ff514e..7b939077e48 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -4,6 +4,7 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState +from freezegun.api import FrozenDateTimeFactory from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -169,6 +170,7 @@ async def test_tracked_clients( async def test_tracked_wireless_clients_event_source( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, mock_device_registry, ) -> None: @@ -234,10 +236,9 @@ async def test_tracked_wireless_clients_event_source( assert hass.states.get("device_tracker.client").state == STATE_HOME # Change time to mark client as away - new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(controller.option_detection_time + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME @@ -274,12 +275,11 @@ async def test_tracked_wireless_clients_event_source( assert hass.states.get("device_tracker.client").state == STATE_HOME # Change time to mark client as away - new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(controller.option_detection_time + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() - assert hass.states.get("device_tracker.client").state == STATE_HOME + assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME async def test_tracked_devices( diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 5248836c08a..638e79ae649 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -141,7 +141,7 @@ async def test_entry_diagnostics( "unique_id": "1", "version": 1, }, - "site_role": "admin", + "role_is_admin": True, "clients": { "00:00:00:00:00:00": { "blocked": False, diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py index 564fd7598d8..38a8cef43c1 100644 --- a/tests/components/unifi/test_image.py +++ b/tests/components/unifi/test_image.py @@ -5,21 +5,18 @@ from datetime import timedelta from http import HTTPStatus from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from syrupy.assertion import SnapshotAssertion from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import ( - EntityCategory, -) +from homeassistant.const import STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_controller import ( - setup_unifi_integration, -) +from .test_controller import setup_unifi_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -106,7 +103,7 @@ async def test_wlan_qr_code( image_state_2 = hass.states.get("image.ssid_1_qr_code") assert image_state_1.state == image_state_2.state - # Update state object - changeed password - new state + # Update state object - changed password - new state data = deepcopy(WLAN) data["x_passphrase"] = "new password" mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=data) @@ -120,3 +117,28 @@ async def test_wlan_qr_code( assert resp.status == HTTPStatus.OK body = await resp.read() assert body == snapshot + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("image.ssid_1_qr_code").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE + + # WLAN gets disabled + wlan_1 = deepcopy(WLAN) + wlan_1["enabled"] = False + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + await hass.async_block_till_done() + assert hass.states.get("image.ssid_1_qr_code").state == STATE_UNAVAILABLE + + # WLAN gets re-enabled + wlan_1["enabled"] = True + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + await hass.async_block_till_done() + assert hass.states.get("image.ssid_1_qr_code").state != STATE_UNAVAILABLE diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index cce26ac84cc..a1b817d67e2 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -24,7 +24,7 @@ async def test_successful_config_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that configured options for a host are loaded via config entry.""" - await setup_unifi_integration(hass, aioclient_mock, unique_id=None) + await setup_unifi_integration(hass, aioclient_mock) assert hass.data[UNIFI_DOMAIN] diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 3d50df8ada9..7ed87512f2b 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -8,7 +8,11 @@ from aiounifi.websocket import WebsocketState import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SCAN_INTERVAL, + SensorDeviceClass, +) from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -19,7 +23,6 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util @@ -132,6 +135,173 @@ WLAN = { "x_passphrase": "password", } +PDU_DEVICE_1 = { + "_id": "123456654321abcdef012345", + "required_version": "5.28.0", + "port_table": [], + "license_state": "registered", + "lcm_brightness_override": False, + "type": "usw", + "board_rev": 4, + "hw_caps": 136, + "reboot_duration": 70, + "snmp_contact": "", + "config_network": {"type": "dhcp", "bonding_enabled": False}, + "outlet_table": [ + { + "index": 1, + "relay_state": True, + "cycle_enabled": False, + "name": "USB Outlet 1", + "outlet_caps": 1, + }, + { + "index": 2, + "relay_state": True, + "cycle_enabled": False, + "name": "Outlet 2", + "outlet_caps": 3, + "outlet_voltage": "119.644", + "outlet_current": "0.935", + "outlet_power": "73.827", + "outlet_power_factor": "0.659", + }, + ], + "model": "USPPDUP", + "manufacturer_id": 4, + "ip": "192.168.1.76", + "fw2_caps": 0, + "jumboframe_enabled": False, + "version": "6.5.59.14777", + "unsupported_reason": 0, + "adoption_completed": True, + "outlet_enabled": True, + "stp_version": "rstp", + "name": "Dummy USP-PDU-Pro", + "fw_caps": 1732968229, + "lcm_brightness": 80, + "internet": True, + "mgmt_network_id": "123456654321abcdef012347", + "gateway_mac": "01:02:03:04:05:06", + "stp_priority": "32768", + "lcm_night_mode_begins": "22:00", + "two_phase_adopt": False, + "connected_at": 1690626493, + "inform_ip": "192.168.1.1", + "cfgversion": "ba8f30a5a17aad64", + "mac": "01:02:03:04:05:ff", + "provisioned_at": 1690989511, + "inform_url": "http://192.168.1.1:8080/inform", + "upgrade_duration": 100, + "ethernet_table": [{"num_port": 1, "name": "eth0", "mac": "01:02:03:04:05:a1"}], + "flowctrl_enabled": False, + "unsupported": False, + "ble_caps": 0, + "sys_error_caps": 0, + "dot1x_portctrl_enabled": False, + "last_uplink": {}, + "disconnected_at": 1690626452, + "architecture": "mips", + "x_aes_gcm": True, + "has_fan": False, + "outlet_overrides": [ + { + "cycle_enabled": False, + "name": "USB Outlet 1", + "relay_state": True, + "index": 1, + }, + {"cycle_enabled": False, "name": "Outlet 2", "relay_state": True, "index": 2}, + ], + "model_incompatible": False, + "satisfaction": 100, + "model_in_eol": False, + "anomalies": -1, + "has_temperature": False, + "switch_caps": {}, + "adopted_by_client": "web", + "snmp_location": "", + "model_in_lts": False, + "kernel_version": "4.14.115", + "serial": "abc123", + "power_source_ctrl_enabled": False, + "lcm_night_mode_ends": "08:00", + "adopted": True, + "hash_id": "abcdef123456", + "device_id": "mock-pdu", + "uplink": {}, + "state": 1, + "start_disconnected_millis": 1690626383386, + "credential_caps": 0, + "default": False, + "discovered_via": "l2", + "adopt_ip": "10.0.10.4", + "adopt_url": "http://192.168.1.1:8080/inform", + "last_seen": 1691518814, + "min_inform_interval_seconds": 10, + "upgradable": False, + "adoptable_when_upgraded": False, + "rollupgrade": False, + "known_cfgversion": "abcfde03929", + "uptime": 1193042, + "_uptime": 1193042, + "locating": False, + "start_connected_millis": 1690626493324, + "prev_non_busy_state": 5, + "next_interval": 47, + "sys_stats": {}, + "system-stats": {"cpu": "1.4", "mem": "28.9", "uptime": "1193042"}, + "ssh_session_table": [], + "lldp_table": [], + "displayable_version": "6.5.59", + "connection_network_id": "123456654321abcdef012349", + "connection_network_name": "Default", + "startup_timestamp": 1690325774, + "is_access_point": False, + "safe_for_autoupgrade": True, + "overheating": False, + "power_source": "0", + "total_max_power": 0, + "outlet_ac_power_budget": "1875.000", + "outlet_ac_power_consumption": "201.683", + "downlink_table": [], + "uplink_depth": 1, + "downlink_lldp_macs": [], + "dhcp_server_table": [], + "connect_request_ip": "10.0.10.4", + "connect_request_port": "57951", + "ipv4_lease_expiration_timestamp_seconds": 1691576686, + "stat": {}, + "tx_bytes": 1426780, + "rx_bytes": 1435064, + "bytes": 2861844, + "num_sta": 0, + "user-num_sta": 0, + "guest-num_sta": 0, + "x_has_ssh_hostkey": True, +} + +PDU_OUTLETS_UPDATE_DATA = [ + { + "index": 1, + "relay_state": True, + "cycle_enabled": False, + "name": "USB Outlet 1", + "outlet_caps": 1, + }, + { + "index": 2, + "relay_state": True, + "cycle_enabled": False, + "name": "Outlet 2", + "outlet_caps": 3, + "outlet_voltage": "119.644", + "outlet_current": "0.935", + "outlet_power": "123.45", + "outlet_power_factor": "0.659", + }, +] + async def test_no_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -166,8 +336,8 @@ async def test_bandwidth_sensors( "mac": "00:00:00:00:00:02", "name": "Wireless client", "oui": "Producer", - "rx_bytes-r": 2345000000, - "tx_bytes-r": 6789000000, + "rx_bytes-r": 2345000000.0, + "tx_bytes-r": 6789000000.0, } options = { CONF_ALLOW_BANDWIDTH_SENSORS: True, @@ -519,7 +689,7 @@ async def test_wlan_client_sensors( ssid_1 = hass.states.get("sensor.ssid_1") assert ssid_1.state == "1" - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() ssid_1 = hass.states.get("sensor.ssid_1") @@ -530,7 +700,7 @@ async def test_wlan_client_sensors( wireless_client_1["essid"] = "SSID" mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_1) - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() ssid_1 = hass.states.get("sensor.ssid_1") @@ -541,7 +711,7 @@ async def test_wlan_client_sensors( wireless_client_2["last_seen"] = 0 mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_2) - async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() ssid_1 = hass.states.get("sensor.ssid_1") @@ -558,3 +728,84 @@ async def test_wlan_client_sensors( mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() assert hass.states.get("sensor.ssid_1").state == "0" + + # WLAN gets disabled + wlan_1 = deepcopy(WLAN) + wlan_1["enabled"] = False + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + await hass.async_block_till_done() + assert hass.states.get("sensor.ssid_1").state == STATE_UNAVAILABLE + + # WLAN gets re-enabled + wlan_1["enabled"] = True + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan_1) + await hass.async_block_till_done() + assert hass.states.get("sensor.ssid_1").state == "0" + + +@pytest.mark.parametrize( + ( + "entity_id", + "expected_unique_id", + "expected_value", + "changed_data", + "expected_update_value", + ), + [ + ( + "dummy_usp_pdu_pro_outlet_2_outlet_power", + "outlet_power-01:02:03:04:05:ff_2", + "73.827", + {"outlet_table": PDU_OUTLETS_UPDATE_DATA}, + "123.45", + ), + ( + "dummy_usp_pdu_pro_ac_power_budget", + "ac_power_budget-01:02:03:04:05:ff", + "1875.000", + None, + None, + ), + ( + "dummy_usp_pdu_pro_ac_power_consumption", + "ac_power_conumption-01:02:03:04:05:ff", + "201.683", + {"outlet_ac_power_consumption": "456.78"}, + "456.78", + ), + ], +) +async def test_outlet_power_readings( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + entity_id: str, + expected_unique_id: str, + expected_value: any, + changed_data: dict | None, + expected_update_value: any, +) -> None: + """Test the outlet power reporting on PDU devices.""" + await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) + + assert len(hass.states.async_all()) == 9 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") + assert ent_reg_entry.unique_id == expected_unique_id + assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC + + sensor_data = hass.states.get(f"sensor.{entity_id}") + assert sensor_data.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + assert sensor_data.state == expected_value + + if changed_data is not None: + updated_device_data = deepcopy(PDU_DEVICE_1) + updated_device_data.update(changed_data) + + mock_unifi_websocket(message=MessageKey.DEVICE, data=updated_device_data) + await hass.async_block_till_done() + + sensor_data = hass.states.get(f"sensor.{entity_id}") + assert sensor_data.state == expected_update_value diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index ad5131614af..d376cab8add 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -4,6 +4,7 @@ from datetime import timedelta from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState +import pytest from homeassistant import config_entries from homeassistant.components.switch import ( @@ -35,8 +36,8 @@ from homeassistant.util import dt as dt_util from .test_controller import ( CONTROLLER_HOST, - DESCRIPTION, ENTRY_CONFIG, + SITE, setup_unifi_integration, ) @@ -384,7 +385,7 @@ OUTLET_UP1 = { "x_vwirekey": "2dabb7e23b048c88b60123456789", "vwire_table": [], "dot1x_portctrl_enabled": False, - "outlet_overrides": [], + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}], "outlet_enabled": True, "license_state": "registered", "x_aes_gcm": True, @@ -580,6 +581,152 @@ OUTLET_UP1 = { } +PDU_DEVICE_1 = { + "_id": "123456654321abcdef012345", + "required_version": "5.28.0", + "port_table": [], + "license_state": "registered", + "lcm_brightness_override": False, + "type": "usw", + "board_rev": 4, + "hw_caps": 136, + "reboot_duration": 70, + "snmp_contact": "", + "config_network": {"type": "dhcp", "bonding_enabled": False}, + "outlet_table": [ + { + "index": 1, + "relay_state": True, + "cycle_enabled": False, + "name": "USB Outlet 1", + "outlet_caps": 1, + }, + { + "index": 2, + "relay_state": True, + "cycle_enabled": False, + "name": "Outlet 2", + "outlet_caps": 3, + "outlet_voltage": "119.644", + "outlet_current": "0.935", + "outlet_power": "73.827", + "outlet_power_factor": "0.659", + }, + ], + "model": "USPPDUP", + "manufacturer_id": 4, + "ip": "192.168.1.76", + "fw2_caps": 0, + "jumboframe_enabled": False, + "version": "6.5.59.14777", + "unsupported_reason": 0, + "adoption_completed": True, + "outlet_enabled": True, + "stp_version": "rstp", + "name": "Dummy USP-PDU-Pro", + "fw_caps": 1732968229, + "lcm_brightness": 80, + "internet": True, + "mgmt_network_id": "123456654321abcdef012347", + "gateway_mac": "01:02:03:04:05:06", + "stp_priority": "32768", + "lcm_night_mode_begins": "22:00", + "two_phase_adopt": False, + "connected_at": 1690626493, + "inform_ip": "192.168.1.1", + "cfgversion": "ba8f30a5a17aad64", + "mac": "01:02:03:04:05:ff", + "provisioned_at": 1690989511, + "inform_url": "http://192.168.1.1:8080/inform", + "upgrade_duration": 100, + "ethernet_table": [{"num_port": 1, "name": "eth0", "mac": "01:02:03:04:05:a1"}], + "flowctrl_enabled": False, + "unsupported": False, + "ble_caps": 0, + "sys_error_caps": 0, + "dot1x_portctrl_enabled": False, + "last_uplink": {}, + "disconnected_at": 1690626452, + "architecture": "mips", + "x_aes_gcm": True, + "has_fan": False, + "outlet_overrides": [ + { + "cycle_enabled": False, + "name": "USB Outlet 1", + "relay_state": True, + "index": 1, + }, + {"cycle_enabled": False, "name": "Outlet 2", "relay_state": True, "index": 2}, + ], + "model_incompatible": False, + "satisfaction": 100, + "model_in_eol": False, + "anomalies": -1, + "has_temperature": False, + "switch_caps": {}, + "adopted_by_client": "web", + "snmp_location": "", + "model_in_lts": False, + "kernel_version": "4.14.115", + "serial": "abc123", + "power_source_ctrl_enabled": False, + "lcm_night_mode_ends": "08:00", + "adopted": True, + "hash_id": "abcdef123456", + "device_id": "mock-pdu", + "uplink": {}, + "state": 1, + "start_disconnected_millis": 1690626383386, + "credential_caps": 0, + "default": False, + "discovered_via": "l2", + "adopt_ip": "10.0.10.4", + "adopt_url": "http://192.168.1.1:8080/inform", + "last_seen": 1691518814, + "min_inform_interval_seconds": 10, + "upgradable": False, + "adoptable_when_upgraded": False, + "rollupgrade": False, + "known_cfgversion": "abcfde03929", + "uptime": 1193042, + "_uptime": 1193042, + "locating": False, + "start_connected_millis": 1690626493324, + "prev_non_busy_state": 5, + "next_interval": 47, + "sys_stats": {}, + "system-stats": {"cpu": "1.4", "mem": "28.9", "uptime": "1193042"}, + "ssh_session_table": [], + "lldp_table": [], + "displayable_version": "6.5.59", + "connection_network_id": "123456654321abcdef012349", + "connection_network_name": "Default", + "startup_timestamp": 1690325774, + "is_access_point": False, + "safe_for_autoupgrade": True, + "overheating": False, + "power_source": "0", + "total_max_power": 0, + "outlet_ac_power_budget": "1875.000", + "outlet_ac_power_consumption": "201.683", + "downlink_table": [], + "uplink_depth": 1, + "downlink_lldp_macs": [], + "dhcp_server_table": [], + "connect_request_ip": "10.0.10.4", + "connect_request_port": "57951", + "ipv4_lease_expiration_timestamp_seconds": 1691576686, + "stat": {}, + "tx_bytes": 1426780, + "rx_bytes": 1435064, + "bytes": 2861844, + "num_sta": 0, + "user-num_sta": 0, + "guest-num_sta": 0, + "x_has_ssh_hostkey": True, +} + WLAN = { "_id": "012345678910111213141516", "bc_filter_enabled": False, @@ -631,7 +778,7 @@ async def test_no_clients( }, ) - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 11 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -656,13 +803,13 @@ async def test_not_admin( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that switch platform only work on an admin account.""" - description = deepcopy(DESCRIPTION) - description[0]["site_role"] = "not admin" + site = deepcopy(SITE) + site[0]["role"] = "not admin" await setup_unifi_integration( hass, aioclient_mock, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, - site_description=description, + sites=site, clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) @@ -720,8 +867,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 11 - assert aioclient_mock.mock_calls[10][2] == { + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[11][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -729,8 +876,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } @@ -747,8 +894,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == {"enabled": False} + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -756,8 +903,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == {"enabled": True} + assert aioclient_mock.call_count == 15 + assert aioclient_mock.mock_calls[14][2] == {"enabled": True} async def test_remove_switches( @@ -843,8 +990,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 11 - assert aioclient_mock.mock_calls[10][2] == { + assert aioclient_mock.call_count == 12 + assert aioclient_mock.mock_calls[11][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -852,8 +999,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } @@ -960,56 +1107,92 @@ async def test_dpi_switches_add_second_app( assert hass.states.get("switch.block_media_streaming").state == STATE_ON +@pytest.mark.parametrize( + ("entity_id", "test_data", "outlet_index", "expected_switches"), + [ + ( + "plug_outlet_1", + OUTLET_UP1, + 1, + 1, + ), + ( + "dummy_usp_pdu_pro_usb_outlet_1", + PDU_DEVICE_1, + 1, + 2, + ), + ( + "dummy_usp_pdu_pro_outlet_2", + PDU_DEVICE_1, + 2, + 2, + ), + ], +) async def test_outlet_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + entity_id: str, + test_data: any, + outlet_index: int, + expected_switches: int, ) -> None: """Test the outlet entities.""" config_entry = await setup_unifi_integration( - hass, aioclient_mock, devices_response=[OUTLET_UP1] + hass, aioclient_mock, devices_response=[test_data] ) controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == expected_switches # Validate state object - switch_1 = hass.states.get("switch.plug_outlet_1") + switch_1 = hass.states.get(f"switch.{entity_id}") assert switch_1 is not None assert switch_1.state == STATE_ON assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET # Update state object - device_1 = deepcopy(OUTLET_UP1) - device_1["outlet_table"][0]["relay_state"] = False + device_1 = deepcopy(test_data) + device_1["outlet_table"][outlet_index - 1]["relay_state"] = False mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Turn off outlet + device_id = test_data["device_id"] aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/600c8356942a6ade50707b56", + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/{device_id}", ) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + {ATTR_ENTITY_ID: f"switch.{entity_id}"}, blocking=True, ) + + expected_off_overrides = deepcopy(device_1["outlet_overrides"]) + expected_off_overrides[outlet_index - 1]["relay_state"] = False + assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[0][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] + "outlet_overrides": expected_off_overrides } # Turn on outlet await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + {ATTR_ENTITY_ID: f"switch.{entity_id}"}, blocking=True, ) + + expected_on_overrides = deepcopy(device_1["outlet_overrides"]) + expected_on_overrides[outlet_index - 1]["relay_state"] = True assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[1][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] + "outlet_overrides": expected_on_overrides } # Availability signalling @@ -1017,33 +1200,33 @@ async def test_outlet_switches( # Controller disconnects mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Controller reconnects mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Device gets disabled device_1["disabled"] = True mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Device gets re-enabled device_1["disabled"] = False mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Unload config entry await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Remove config entry await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1") is None + assert hass.states.get(f"switch.{entity_id}") is None async def test_new_client_discovered_on_block_control( @@ -1162,6 +1345,9 @@ async def test_poe_port_switches( ent_reg.async_update_entity( entity_id="switch.mock_name_port_1_poe", disabled_by=None ) + ent_reg.async_update_entity( + entity_id="switch.mock_name_port_2_poe", disabled_by=None + ) await hass.async_block_till_done() async_fire_time_changed( @@ -1195,6 +1381,8 @@ async def test_poe_port_switches( {"entity_id": "switch.mock_name_port_1_poe"}, blocking=True, ) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[0][2] == { "port_overrides": [{"poe_mode": "off", "port_idx": 1, "portconf_id": "1a1"}] @@ -1207,9 +1395,20 @@ async def test_poe_port_switches( {"entity_id": "switch.mock_name_port_1_poe"}, blocking=True, ) + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_2_poe"}, + blocking=True, + ) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[1][2] == { - "port_overrides": [{"poe_mode": "auto", "port_idx": 1, "portconf_id": "1a1"}] + "port_overrides": [ + {"poe_mode": "auto", "port_idx": 1, "portconf_id": "1a1"}, + {"poe_mode": "off", "port_idx": 2, "portconf_id": "1a2"}, + ] } # Availability signalling @@ -1335,3 +1534,90 @@ async def test_wlan_switches( mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() assert hass.states.get("switch.ssid_1").state == STATE_OFF + + +async def test_port_forwarding_switches( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Test control of UniFi port forwarding.""" + _data = { + "_id": "5a32aa4ee4b0412345678911", + "dst_port": "12345", + "enabled": True, + "fwd_port": "23456", + "fwd": "10.0.0.2", + "name": "plex", + "pfwd_interface": "wan", + "proto": "tcp_udp", + "site_id": "5a32aa4ee4b0412345678910", + "src": "any", + } + config_entry = await setup_unifi_integration( + hass, aioclient_mock, port_forward_response=[_data.copy()] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("switch.unifi_network_plex") + assert ent_reg_entry.unique_id == "port_forward-5a32aa4ee4b0412345678911" + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Validate state object + switch_1 = hass.states.get("switch.unifi_network_plex") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.SWITCH + + # Update state object + data = _data.copy() + data["enabled"] = False + mock_unifi_websocket(message=MessageKey.PORT_FORWARD_UPDATED, data=data) + await hass.async_block_till_done() + assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF + + # Disable port forward + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}" + + f"/rest/portforward/{data['_id']}", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_plex"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + data = _data.copy() + data["enabled"] = False + assert aioclient_mock.mock_calls[0][2] == data + + # Enable port forward + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_plex"}, + blocking=True, + ) + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == _data + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("switch.unifi_network_plex").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF + + # Remove entity on deleted message + mock_unifi_websocket(message=MessageKey.PORT_FORWARD_DELETED, data=_data) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 7cf8495b9db..e59eca371d6 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_controller import DESCRIPTION, setup_unifi_integration +from .test_controller import SITE, setup_unifi_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -136,14 +136,11 @@ async def test_not_admin( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that the INSTALL feature is not available on a non-admin account.""" - description = deepcopy(DESCRIPTION) - description[0]["site_role"] = "not admin" + site = deepcopy(SITE) + site[0]["role"] = "not admin" await setup_unifi_integration( - hass, - aioclient_mock, - site_description=description, - devices_response=[DEVICE_1], + hass, aioclient_mock, sites=site, devices_response=[DEVICE_1] ) assert len(hass.states.async_entity_ids(UPDATE_DOMAIN)) == 1 diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index e19985aea3f..c5690ef5e92 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -5,7 +5,6 @@ from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, patch import pytest -import pytz from pyunifiprotect.data import ( Bootstrap, Camera, @@ -441,7 +440,7 @@ ONE_MONTH_SIMPLE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 1, ) @@ -454,7 +453,7 @@ TWO_MONTH_SIMPLE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 2, ) @@ -513,7 +512,7 @@ ONE_MONTH_TIMEZONE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 1, ) @@ -526,7 +525,7 @@ TWO_MONTH_TIMEZONE = ( minute=0, second=0, microsecond=0, - tzinfo=pytz.timezone("US/Pacific"), + tzinfo=dt_util.get_time_zone("US/Pacific"), ), 2, ) diff --git a/tests/components/upb/test_config_flow.py b/tests/components/upb/test_config_flow.py index 40f2b5591f1..d2fbe27248d 100644 --- a/tests/components/upb/test_config_flow.py +++ b/tests/components/upb/test_config_flow.py @@ -82,7 +82,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: from asyncio import TimeoutError with patch( - "homeassistant.components.upb.config_flow.async_timeout.timeout", + "homeassistant.components.upb.config_flow.asyncio.timeout", side_effect=TimeoutError, ): result = await valid_tcp_flow(hass, sync_complete=False) diff --git a/tests/components/upcloud/test_config_flow.py b/tests/components/upcloud/test_config_flow.py index eadbe1c8fe6..cc869cdb99b 100644 --- a/tests/components/upcloud/test_config_flow.py +++ b/tests/components/upcloud/test_config_flow.py @@ -6,10 +6,11 @@ import requests_mock from requests_mock import ANY from upcloud_api import UpCloudAPIError -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components.upcloud.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -29,7 +30,7 @@ async def test_show_set_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -42,7 +43,7 @@ async def test_connection_error( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} @@ -63,7 +64,7 @@ async def test_login_error( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT ) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_auth"} @@ -78,7 +79,7 @@ async def test_success( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] assert result["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] @@ -96,7 +97,7 @@ async def test_options(hass: HomeAssistant) -> None: await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( @@ -108,7 +109,7 @@ async def test_options(hass: HomeAssistant) -> None: ) -async def test_already_configured(hass, requests_mock): +async def test_already_configured(hass: HomeAssistant, requests_mock) -> None: """Test duplicate entry aborts and updates data.""" config_entry = MockConfigEntry( @@ -127,7 +128,7 @@ async def test_already_configured(hass, requests_mock): DOMAIN, context={"source": config_entries.SOURCE_USER}, data=new_user_input ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" assert config_entry.data[CONF_USERNAME] == new_user_input[CONF_USERNAME] assert config_entry.data[CONF_PASSWORD] == new_user_input[CONF_PASSWORD] diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index a7780f54f70..73f98c9e2db 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -1,4 +1,5 @@ """The tests for the Update component.""" +from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest @@ -24,6 +25,7 @@ from homeassistant.components.update.const import ( ATTR_TITLE, UpdateEntityFeature, ) +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, @@ -34,12 +36,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component -from tests.common import MockEntityPlatform, mock_restore_cache +from tests.common import ( + MockConfigEntry, + MockEntityPlatform, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) from tests.typing import WebSocketGenerator +TEST_DOMAIN = "test" + class MockUpdateEntity(UpdateEntity): """Mock UpdateEntity to use in tests.""" @@ -752,3 +766,101 @@ async def test_release_notes_entity_does_not_support_release_notes( result = await client.receive_json() assert result["error"]["code"] == "not_supported" assert result["error"]["message"] == "Entity does not support release notes" + + +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 + + +async def test_name(hass: HomeAssistant) -> None: + """Test update name.""" + + 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 + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed update entity without device class -> no name + entity1 = UpdateEntity() + entity1.entity_id = "update.test1" + + # Unnamed update entity with device class but has_entity_name False -> no name + entity2 = UpdateEntity() + entity2.entity_id = "update.test2" + entity2._attr_device_class = UpdateDeviceClass.FIRMWARE + + # Unnamed update entity with device class and has_entity_name True -> named + entity3 = UpdateEntity() + entity3.entity_id = "update.test3" + entity3._attr_device_class = UpdateDeviceClass.FIRMWARE + entity3._attr_has_entity_name = True + + # Unnamed update entity with device class and has_entity_name True -> named + entity4 = UpdateEntity() + entity4.entity_id = "update.test4" + entity4.entity_description = UpdateEntityDescription( + "test", + UpdateDeviceClass.FIRMWARE, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test update platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + 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() + + state = hass.states.get(entity1.entity_id) + assert state + assert "device_class" not in state.attributes + assert "friendly_name" not in state.attributes + + state = hass.states.get(entity2.entity_id) + assert state + assert state.attributes.get("device_class") == "firmware" + assert "friendly_name" not in state.attributes + + expected = { + "device_class": "firmware", + "friendly_name": "Firmware", + } + state = hass.states.get(entity3.entity_id) + assert state + assert expected.items() <= state.attributes.items() + + state = hass.states.get(entity4.entity_id) + assert state + assert expected.items() <= state.attributes.items() diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index bba5af07be3..67fac2437f0 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -1,6 +1,7 @@ """Test the UptimeRobot init.""" from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException @@ -12,7 +13,6 @@ from homeassistant.components.uptimerobot.const import ( from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -93,7 +93,9 @@ async def test_reauthentication_trigger_key_read_only( async def test_reauthentication_trigger_after_setup( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test reauthentication trigger.""" mock_config_entry = await setup_uptimerobot_integration(hass) @@ -106,7 +108,8 @@ async def test_reauthentication_trigger_after_setup( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -125,7 +128,10 @@ async def test_reauthentication_trigger_after_setup( assert flow["context"]["entry_id"] == mock_config_entry.entry_id -async def test_integration_reload(hass: HomeAssistant) -> None: +async def test_integration_reload( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test integration reload.""" mock_entry = await setup_uptimerobot_integration(hass) @@ -134,7 +140,8 @@ async def test_integration_reload(hass: HomeAssistant) -> None: return_value=mock_uptimerobot_api_response(), ): assert await hass.config_entries.async_reload(mock_entry.entry_id) - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(mock_entry.entry_id) @@ -143,7 +150,9 @@ async def test_integration_reload(hass: HomeAssistant) -> None: async def test_update_errors( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test errors during updates.""" await setup_uptimerobot_integration(hass) @@ -152,7 +161,8 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotException, ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -163,7 +173,8 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON @@ -171,7 +182,8 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -181,7 +193,10 @@ async def test_update_errors( assert "Error fetching uptimerobot data: test error from API" in caplog.text -async def test_device_management(hass: HomeAssistant) -> None: +async def test_device_management( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test that we are adding and removing devices for monitors returned from the API.""" mock_entry = await setup_uptimerobot_integration(hass) dev_reg = dr.async_get(hass) @@ -201,7 +216,8 @@ async def test_device_management(hass: HomeAssistant) -> None: data=[MOCK_UPTIMEROBOT_MONITOR, {**MOCK_UPTIMEROBOT_MONITOR, "id": 12345}] ), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(dev_reg, mock_entry.entry_id) @@ -218,7 +234,8 @@ async def test_device_management(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(), ): - async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + freezer.tick(COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() 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 88a77407c07..262dbf36306 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -277,6 +277,7 @@ async def test_change_device_source(hass: HomeAssistant) -> None: # Configure source entity 1 (with a linked device) source_config_entry_1 = MockConfigEntry() + source_config_entry_1.add_to_hass(hass) source_device_entry_1 = device_registry.async_get_or_create( config_entry_id=source_config_entry_1.entry_id, identifiers={("sensor", "identifier_test1")}, @@ -292,6 +293,7 @@ async def test_change_device_source(hass: HomeAssistant) -> None: # Configure source entity 2 (with a linked device) source_config_entry_2 = MockConfigEntry() + source_config_entry_2.add_to_hass(hass) source_device_entry_2 = device_registry.async_get_or_create( config_entry_id=source_config_entry_2.entry_id, identifiers={("sensor", "identifier_test2")}, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 3d2d95fd26f..b8f197a4dee 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1466,6 +1466,7 @@ async def test_device_id(hass: HomeAssistant) -> None: entity_registry = er.async_get(hass) source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) source_device_entry = device_registry.async_get_or_create( config_entry_id=source_config_entry.entry_id, identifiers={("sensor", "identifier_test")}, diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py index 0c532d9007d..dd42cfc2977 100644 --- a/tests/components/uvc/test_camera.py +++ b/tests/components/uvc/test_camera.py @@ -1,5 +1,5 @@ """The tests for UVC camera module.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import call, patch import pytest @@ -368,7 +368,7 @@ async def test_motion_recording_mode_properties( assert state assert state.state != STATE_RECORDING assert state.attributes["last_recording_start_time"] == datetime( - 2021, 1, 8, 1, 56, 32, 367000, tzinfo=timezone.utc + 2021, 1, 8, 1, 56, 32, 367000, tzinfo=UTC ) mock_remote.return_value.get_camera.return_value["recordingIndicator"] = "DISABLED" diff --git a/tests/components/verisure/conftest.py b/tests/components/verisure/conftest.py index 8ddc3a99815..8e1da712a5c 100644 --- a/tests/components/verisure/conftest.py +++ b/tests/components/verisure/conftest.py @@ -23,6 +23,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_GIID: "12345", CONF_PASSWORD: "SuperS3cr3t!", }, + version=2, ) diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index af102cced98..94a0963fdf6 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -11,7 +11,6 @@ from homeassistant.components import dhcp from homeassistant.components.verisure.const import ( CONF_GIID, CONF_LOCK_CODE_DIGITS, - CONF_LOCK_DEFAULT_CODE, DEFAULT_LOCK_CODE_DIGITS, DOMAIN, ) @@ -561,48 +560,9 @@ async def test_reauth_flow_errors( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize( - ("input", "output"), - [ - ( - { - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "12345", - }, - { - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "12345", - }, - ), - ( - { - CONF_LOCK_DEFAULT_CODE: "", - }, - { - CONF_LOCK_DEFAULT_CODE: "", - CONF_LOCK_CODE_DIGITS: DEFAULT_LOCK_CODE_DIGITS, - }, - ), - ( - { - CONF_LOCK_CODE_DIGITS: 5, - }, - { - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "", - }, - ), - ], -) -async def test_options_flow( - hass: HomeAssistant, input: dict[str, int | str], output: dict[str, int | str] -) -> None: +async def test_options_flow(hass: HomeAssistant) -> None: """Test options config flow.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="12345", - data={}, - ) + entry = MockConfigEntry(domain=DOMAIN, unique_id="12345", data={}, version=2) entry.add_to_hass(hass) with patch( @@ -619,43 +579,8 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input=input, + user_input={CONF_LOCK_CODE_DIGITS: 4}, ) assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("data") == output - - -async def test_options_flow_code_format_mismatch(hass: HomeAssistant) -> None: - """Test options config flow with a code format mismatch.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="12345", - data={}, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.verisure.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == "init" - assert result.get("errors") == {} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_LOCK_CODE_DIGITS: 5, - CONF_LOCK_DEFAULT_CODE: "123", - }, - ) - - assert result.get("type") == FlowResultType.FORM - assert result.get("step_id") == "init" - assert result.get("errors") == {"base": "code_format_mismatch"} + assert result.get("data") == {CONF_LOCK_CODE_DIGITS: DEFAULT_LOCK_CODE_DIGITS} diff --git a/tests/components/version/common.py b/tests/components/version/common.py index 3e3ae6c3970..c4759604a44 100644 --- a/tests/components/version/common.py +++ b/tests/components/version/common.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any, Final from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant import config_entries from homeassistant.components.version.const import ( DEFAULT_CONFIGURATION, @@ -14,7 +16,6 @@ from homeassistant.components.version.const import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -37,6 +38,7 @@ TEST_DEFAULT_IMPORT_CONFIG: Final = { async def mock_get_version_update( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, version: str = MOCK_VERSION, data: dict[str, Any] = MOCK_VERSION_DATA, side_effect: Exception = None, @@ -47,9 +49,8 @@ async def mock_get_version_update( return_value=(version, data), side_effect=side_effect, ): - async_fire_time_changed( - hass, dt_util.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL - ) + freezer.tick(UPDATE_COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py index 1c7f9040b22..0a3e89494f1 100644 --- a/tests/components/version/test_sensor.py +++ b/tests/components/version/test_sensor.py @@ -1,6 +1,7 @@ """The test for the version sensor platform.""" from __future__ import annotations +from freezegun.api import FrozenDateTimeFactory from pyhaversion.exceptions import HaVersionException import pytest @@ -19,15 +20,19 @@ async def test_version_sensor(hass: HomeAssistant) -> None: assert "channel" not in state.attributes -async def test_update(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: +async def test_update( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: """Test updates.""" await setup_version_integration(hass) assert hass.states.get("sensor.local_installation").state == MOCK_VERSION - await mock_get_version_update(hass, version="1970.1.1") + await mock_get_version_update(hass, freezer, version="1970.1.1") assert hass.states.get("sensor.local_installation").state == "1970.1.1" assert "Error fetching version data" not in caplog.text - await mock_get_version_update(hass, side_effect=HaVersionException) + await mock_get_version_update(hass, freezer, side_effect=HaVersionException) assert hass.states.get("sensor.local_installation").state == "unavailable" assert "Error fetching version data" in caplog.text diff --git a/tests/components/vodafone_station/__init__.py b/tests/components/vodafone_station/__init__.py new file mode 100644 index 00000000000..68f11a27b95 --- /dev/null +++ b/tests/components/vodafone_station/__init__.py @@ -0,0 +1 @@ +"""Tests for the Vodafone Station integration.""" diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py new file mode 100644 index 00000000000..40dc305630e --- /dev/null +++ b/tests/components/vodafone_station/const.py @@ -0,0 +1,17 @@ +"""Common stuff for Vodafone Station tests.""" +from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + ] + } +} + +MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py new file mode 100644 index 00000000000..03a1198288d --- /dev/null +++ b/tests/components/vodafone_station/test_config_flow.py @@ -0,0 +1,215 @@ +"""Tests for Vodafone Station config flow.""" +from unittest.mock import patch + +from aiovodafone import exceptions as aiovodafone_exceptions +import pytest + +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 +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_user(hass: HomeAssistant) -> None: + """Test starting a flow by user.""" + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.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 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_USERNAME] == "fake_username" + assert result["data"][CONF_PASSWORD] == "fake_password" + assert not result["result"].unique_id + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (aiovodafone_exceptions.CannotConnect, "cannot_connect"), + (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a flow by user with a connection error.""" + + 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( + "aiovodafone.api.VodafoneStationApi.login", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # Should be recoverable after hits error + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + return_value={ + "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", + "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", + }, + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "fake_host" + assert result2["data"] == { + "host": "fake_host", + "username": "fake_username", + "password": "fake_password", + } + + +async def test_reauth_successful(hass: HomeAssistant) -> None: + """Test starting a reauthentication flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.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}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (aiovodafone_exceptions.CannotConnect, "cannot_connect"), + (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a reauthentication flow but no connection found.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + side_effect=side_effect, + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == error + + # Should be recoverable after hits error + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + return_value={ + "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", + "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", + }, + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 9b3f5d963dc..361e4e7f0e2 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -3,7 +3,6 @@ import asyncio import time from unittest.mock import AsyncMock, Mock, patch -import async_timeout import pytest from homeassistant.components import assist_pipeline, voip @@ -118,7 +117,7 @@ async def test_pipeline( rtp_protocol.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() @@ -159,7 +158,7 @@ async def test_pipeline_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> rtp_protocol.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to time out - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() @@ -200,7 +199,7 @@ async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) rtp_protocol.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to time out - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() @@ -319,5 +318,5 @@ async def test_tts_timeout( rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) # Wait for mock pipeline to exhaust the audio stream - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await done.wait() diff --git a/tests/components/wake_word/__init__.py b/tests/components/wake_word/__init__.py new file mode 100644 index 00000000000..ed2fe81a7fe --- /dev/null +++ b/tests/components/wake_word/__init__.py @@ -0,0 +1 @@ +"""Wake-word-detection tests.""" diff --git a/tests/components/wake_word/common.py b/tests/components/wake_word/common.py new file mode 100644 index 00000000000..f732044bc13 --- /dev/null +++ b/tests/components/wake_word/common.py @@ -0,0 +1,29 @@ +"""Provide common test tools for wake-word-detection.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from pathlib import Path +from typing import Any + +from homeassistant.components import wake_word +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import MockPlatform, mock_platform + + +def mock_wake_word_entity_platform( + hass: HomeAssistant, + tmp_path: Path, + integration: str, + async_setup_entry: Callable[ + [HomeAssistant, ConfigEntry, AddEntitiesCallback], + Coroutine[Any, Any, None], + ] + | None = None, +) -> MockPlatform: + """Specialize the mock platform for stt.""" + loaded_platform = MockPlatform(async_setup_entry=async_setup_entry) + mock_platform(hass, f"{integration}.{wake_word.DOMAIN}", loaded_platform) + return loaded_platform diff --git a/tests/components/wake_word/snapshots/test_init.ambr b/tests/components/wake_word/snapshots/test_init.ambr new file mode 100644 index 00000000000..cf7c09cd730 --- /dev/null +++ b/tests/components/wake_word/snapshots/test_init.ambr @@ -0,0 +1,14 @@ +# serializer version: 1 +# name: test_detected_entity + None +# --- +# name: test_ws_detect + dict({ + 'event': dict({ + 'timestamp': 2048.0, + 'ww_id': 'test_ww', + }), + 'id': 1, + 'type': 'event', + }) +# --- diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py new file mode 100644 index 00000000000..d37cb3aa540 --- /dev/null +++ b/tests/components/wake_word/test_init.py @@ -0,0 +1,246 @@ +"""Test wake_word component setup.""" +from collections.abc import AsyncIterable, Generator +from pathlib import Path + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import wake_word +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component + +from .common import mock_wake_word_entity_platform + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, +) + +TEST_DOMAIN = "test" + +_SAMPLES_PER_CHUNK = 1024 +_BYTES_PER_CHUNK = _SAMPLES_PER_CHUNK * 2 # 16-bit +_MS_PER_CHUNK = (_BYTES_PER_CHUNK // 2) // 16 # 16Khz + + +class MockProviderEntity(wake_word.WakeWordDetectionEntity): + """Mock provider entity.""" + + url_path = "wake_word.test" + _attr_name = "test" + + @property + def supported_wake_words(self) -> list[wake_word.WakeWord]: + """Return a list of supported wake words.""" + return [wake_word.WakeWord(ww_id="test_ww", name="Test Wake Word")] + + async def _async_process_audio_stream( + self, stream: AsyncIterable[tuple[bytes, int]] + ) -> wake_word.DetectionResult | None: + """Try to detect wake word(s) in an audio stream with timestamps.""" + async for _chunk, timestamp in stream: + if timestamp >= 2000: + return wake_word.DetectionResult( + ww_id=self.supported_wake_words[0].ww_id, timestamp=timestamp + ) + + # Not detected + return None + + +@pytest.fixture +def mock_provider_entity() -> MockProviderEntity: + """Test provider entity fixture.""" + return MockProviderEntity() + + +class WakeWordFlow(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, WakeWordFlow): + yield + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + tmp_path: Path, +) -> MockProviderEntity: + """Set up the test environment.""" + provider = MockProviderEntity() + await mock_config_entry_setup(hass, tmp_path, provider) + + return provider + + +async def mock_config_entry_setup( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> MockConfigEntry: + """Set up a test provider via config entry.""" + + 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, wake_word.DOMAIN + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, wake_word.DOMAIN + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([mock_provider_entity]) + + mock_wake_word_entity_platform( + hass, tmp_path, TEST_DOMAIN, 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 + + +async def test_config_entry_unload( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> None: + """Test we can unload config entry.""" + config_entry = await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + assert config_entry.state == ConfigEntryState.LOADED + await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_detected_entity( + hass: HomeAssistant, + tmp_path: Path, + setup: MockProviderEntity, + snapshot: SnapshotAssertion, +) -> None: + """Test successful detection through entity.""" + + async def three_second_stream(): + timestamp = 0 + while timestamp < 3000: + yield bytes(_BYTES_PER_CHUNK), timestamp + timestamp += _MS_PER_CHUNK + + # Need 2 seconds to trigger + state = setup.state + result = await setup.async_process_audio_stream(three_second_stream()) + assert result == wake_word.DetectionResult("test_ww", 2048) + + assert state != setup.state + assert state == snapshot + + +async def test_not_detected_entity( + hass: HomeAssistant, setup: MockProviderEntity +) -> None: + """Test unsuccessful detection through entity.""" + + async def one_second_stream(): + timestamp = 0 + while timestamp < 1000: + yield bytes(_BYTES_PER_CHUNK), timestamp + timestamp += _MS_PER_CHUNK + + # Need 2 seconds to trigger + state = setup.state + result = await setup.async_process_audio_stream(one_second_stream()) + assert result is None + + # State should only change when there's a detection + assert state == setup.state + + +async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: + """Test async_default_engine.""" + assert await async_setup_component(hass, wake_word.DOMAIN, {wake_word.DOMAIN: {}}) + await hass.async_block_till_done() + + assert wake_word.async_default_engine(hass) is None + + +async def test_default_engine_entity( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> None: + """Test async_default_engine.""" + await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + + assert wake_word.async_default_engine(hass) == f"{wake_word.DOMAIN}.{TEST_DOMAIN}" + + +async def test_get_engine_entity( + hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity +) -> None: + """Test async_get_speech_to_text_engine.""" + await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + + assert ( + wake_word.async_get_wake_word_detection_entity(hass, f"{wake_word.DOMAIN}.test") + is mock_provider_entity + ) + + +async def test_restore_state( + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{wake_word.DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, tmp_path, mock_provider_entity) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +async def test_entity_attributes( + hass: HomeAssistant, mock_provider_entity: MockProviderEntity +) -> None: + """Test that the provider entity attributes match expectations.""" + assert mock_provider_entity.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 66276f0bc88..bc996ab6fa4 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -71,14 +71,12 @@ async def test_sync_turn_on(hass: HomeAssistant) -> None: setattr(water_heater, "turn_on", MagicMock()) await water_heater.async_turn_on() - # pylint: disable-next=no-member assert water_heater.turn_on.call_count == 1 # Test with async_turn_on method defined setattr(water_heater, "async_turn_on", AsyncMock()) await water_heater.async_turn_on() - # pylint: disable-next=no-member assert water_heater.async_turn_on.call_count == 1 @@ -91,12 +89,10 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: setattr(water_heater, "turn_off", MagicMock()) await water_heater.async_turn_off() - # pylint: disable-next=no-member assert water_heater.turn_off.call_count == 1 # Test with async_turn_off method defined setattr(water_heater, "async_turn_off", AsyncMock()) await water_heater.async_turn_off() - # pylint: disable-next=no-member assert water_heater.async_turn_off.call_count == 1 diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..e1cf4a8a42f --- /dev/null +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'ba': 'CAISO_NORTH', + 'freq': '300', + 'moer': '850.743982', + 'percent': '53', + 'point_time': '2019-01-29T14:55:00.00Z', + }), + 'entry': dict({ + 'data': dict({ + 'balancing_authority': '**REDACTED**', + 'balancing_authority_abbreviation': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'watttime', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/watttime/test_diagnostics.py b/tests/components/watttime/test_diagnostics.py index 8f40e8dbcd2..1f45ba870fc 100644 --- a/tests/components/watttime/test_diagnostics.py +++ b/tests/components/watttime/test_diagnostics.py @@ -1,5 +1,7 @@ """Test WattTime diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion +from syrupy.filters import props + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,34 +13,9 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_watttime, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "watttime", - "title": REDACTED, - "data": { - "username": REDACTED, - "password": REDACTED, - "latitude": REDACTED, - "longitude": REDACTED, - "balancing_authority": REDACTED, - "balancing_authority_abbreviation": REDACTED, - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "freq": "300", - "ba": "CAISO_NORTH", - "percent": "53", - "moer": "850.743982", - "point_time": "2019-01-29T14:55:00.00Z", - }, - } + assert await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) == snapshot(exclude=props("entry_id")) diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index 65c2616d1dc..64c05a5dcc1 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -2,42 +2,31 @@ from unittest.mock import patch import pytest -from WazeRouteCalculator import WRCError - - -@pytest.fixture(name="mock_wrc", autouse=True) -def mock_wrc_fixture(): - """Mock out WazeRouteCalculator.""" - with patch( - "homeassistant.components.waze_travel_time.sensor.WazeRouteCalculator" - ) as mock_wrc: - yield mock_wrc +from pywaze.route_calculator import WRCError @pytest.fixture(name="mock_update") -def mock_update_fixture(mock_wrc): +def mock_update_fixture(): """Mock an update to the sensor.""" - obj = mock_wrc.return_value - obj.calc_all_routes_info.return_value = {"My route": (150, 300)} + with patch( + "pywaze.route_calculator.WazeRouteCalculator.calc_all_routes_info", + return_value={"My route": (150, 300)}, + ) as mock_wrc: + yield mock_wrc @pytest.fixture(name="validate_config_entry") -def validate_config_entry_fixture(): +def validate_config_entry_fixture(mock_update): """Return valid config entry.""" - with patch( - "homeassistant.components.waze_travel_time.helpers.WazeRouteCalculator" - ) as mock_wrc: - obj = mock_wrc.return_value - obj.calc_all_routes_info.return_value = None - yield mock_wrc + mock_update.return_value = None + return mock_update @pytest.fixture(name="invalidate_config_entry") def invalidate_config_entry_fixture(validate_config_entry): """Return invalid config entry.""" - obj = validate_config_entry.return_value - obj.calc_all_routes_info.return_value = {} - obj.calc_all_routes_info.side_effect = WRCError("test") + validate_config_entry.side_effect = WRCError("test") + return validate_config_entry @pytest.fixture(name="bypass_platform_setup") diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index a3367a48d2a..adcc334889d 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -1,6 +1,6 @@ """Test Waze Travel Time sensors.""" import pytest -from WazeRouteCalculator import WRCError +from pywaze.route_calculator import WRCError from homeassistant.components.waze_travel_time.const import ( CONF_AVOID_FERRIES, @@ -35,17 +35,17 @@ async def mock_config_fixture(hass, data, options): @pytest.fixture(name="mock_update_wrcerror") -def mock_update_wrcerror_fixture(mock_wrc): +def mock_update_wrcerror_fixture(mock_update): """Mock an update to the sensor failed with WRCError.""" - obj = mock_wrc.return_value - obj.calc_all_routes_info.side_effect = WRCError("test") + mock_update.side_effect = WRCError("test") + return mock_update @pytest.fixture(name="mock_update_keyerror") -def mock_update_keyerror_fixture(mock_wrc): +def mock_update_keyerror_fixture(mock_update): """Mock an update to the sensor failed with KeyError.""" - obj = mock_wrc.return_value - obj.calc_all_routes_info.side_effect = KeyError("test") + mock_update.side_effect = KeyError("test") + return mock_update @pytest.mark.parametrize( diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 92643b616c9..db3a18db914 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1,5 +1,6 @@ """The test for weather entity.""" from datetime import datetime +from typing import Any import pytest @@ -14,7 +15,6 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_UV_INDEX, - ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -31,7 +31,9 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, + DOMAIN, ROUNDING_PRECISION, + SERVICE_GET_FORECAST, Forecast, WeatherEntity, WeatherEntityFeature, @@ -43,7 +45,6 @@ from homeassistant.components.weather.const import ( ATTR_WEATHER_HUMIDITY, ) from homeassistant.const import ( - ATTR_FRIENDLY_NAME, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, @@ -53,7 +54,9 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -67,6 +70,9 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from . import create_entity from tests.testing_config.custom_components.test import weather as WeatherPlatform +from tests.testing_config.custom_components.test_weather import ( + weather as NewWeatherPlatform, +) from tests.typing import WebSocketGenerator @@ -120,31 +126,6 @@ class MockWeatherEntityPrecision(WeatherEntity): self._attr_precision = PRECISION_HALVES -class MockWeatherEntityCompat(WeatherEntity): - """Mock a Weather Entity using old attributes.""" - - def __init__(self) -> None: - """Initiate Entity.""" - super().__init__() - self._attr_condition = ATTR_CONDITION_SUNNY - self._attr_precipitation_unit = UnitOfLength.MILLIMETERS - self._attr_pressure = 10 - self._attr_pressure_unit = UnitOfPressure.HPA - self._attr_temperature = 20 - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_visibility = 30 - self._attr_visibility_unit = UnitOfLength.KILOMETERS - self._attr_wind_speed = 3 - self._attr_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - self._attr_forecast = [ - Forecast( - datetime=datetime(2022, 6, 20, 0, 00, 00, tzinfo=dt_util.UTC), - precipitation=1, - temperature=20, - ) - ] - - @pytest.mark.parametrize( "native_unit", (UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) ) @@ -767,219 +748,6 @@ async def test_custom_units( ) -async def test_backwards_compatibility( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: - """Test backwards compatibility.""" - wind_speed_value = 5 - wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - pressure_value = 110000 - pressure_unit = UnitOfPressure.PA - temperature_value = 20 - temperature_unit = UnitOfTemperature.CELSIUS - visibility_value = 11 - visibility_unit = UnitOfLength.KILOMETERS - precipitation_value = 1 - precipitation_unit = UnitOfLength.MILLIMETERS - - hass.config.units = METRIC_SYSTEM - - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecastCompat( - name="Test", - condition=ATTR_CONDITION_SUNNY, - temperature=temperature_value, - temperature_unit=temperature_unit, - wind_speed=wind_speed_value, - wind_speed_unit=wind_speed_unit, - pressure=pressure_value, - pressure_unit=pressure_unit, - visibility=visibility_value, - visibility_unit=visibility_unit, - precipitation=precipitation_value, - precipitation_unit=precipitation_unit, - unique_id="very_unique", - ) - ) - platform.ENTITIES.append( - platform.MockWeatherMockForecastCompat( - name="Test2", - condition=ATTR_CONDITION_SUNNY, - temperature=temperature_value, - temperature_unit=temperature_unit, - wind_speed=wind_speed_value, - pressure=pressure_value, - visibility=visibility_value, - precipitation=precipitation_value, - unique_id="very_unique2", - ) - ) - - entity0 = platform.ENTITIES[0] - entity1 = platform.ENTITIES[1] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test2"}} - ) - await hass.async_block_till_done() - - state = hass.states.get(entity0.entity_id) - forecast = state.attributes[ATTR_FORECAST][0] - state1 = hass.states.get(entity1.entity_id) - forecast1 = state1.attributes[ATTR_FORECAST][0] - - assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == pytest.approx( - wind_speed_value * 3.6 - ) - assert ( - state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == pytest.approx( - temperature_value, rel=0.1 - ) - assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == UnitOfTemperature.CELSIUS - assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == pytest.approx( - pressure_value / 100 - ) - assert state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == UnitOfPressure.HPA - assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == pytest.approx( - visibility_value - ) - assert state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == UnitOfLength.KILOMETERS - assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == pytest.approx( - precipitation_value, rel=1e-2 - ) - assert state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == UnitOfLength.MILLIMETERS - - assert float(state1.attributes[ATTR_WEATHER_WIND_SPEED]) == pytest.approx( - wind_speed_value - ) - assert ( - state1.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert float(state1.attributes[ATTR_WEATHER_TEMPERATURE]) == pytest.approx( - temperature_value, rel=0.1 - ) - assert state1.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == UnitOfTemperature.CELSIUS - assert float(state1.attributes[ATTR_WEATHER_PRESSURE]) == pytest.approx( - pressure_value - ) - assert state1.attributes[ATTR_WEATHER_PRESSURE_UNIT] == UnitOfPressure.HPA - assert float(state1.attributes[ATTR_WEATHER_VISIBILITY]) == pytest.approx( - visibility_value - ) - assert state1.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == UnitOfLength.KILOMETERS - assert float(forecast1[ATTR_FORECAST_PRECIPITATION]) == pytest.approx( - precipitation_value, rel=1e-2 - ) - assert ( - state1.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == UnitOfLength.MILLIMETERS - ) - - -async def test_backwards_compatibility_convert_values( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: - """Test backward compatibility for converting values.""" - wind_speed_value = 5 - wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - pressure_value = 110000 - pressure_unit = UnitOfPressure.PA - temperature_value = 20 - temperature_unit = UnitOfTemperature.CELSIUS - visibility_value = 11 - visibility_unit = UnitOfLength.KILOMETERS - precipitation_value = 1 - precipitation_unit = UnitOfLength.MILLIMETERS - - hass.config.units = US_CUSTOMARY_SYSTEM - - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecastCompat( - name="Test", - condition=ATTR_CONDITION_SUNNY, - temperature=temperature_value, - temperature_unit=temperature_unit, - wind_speed=wind_speed_value, - wind_speed_unit=wind_speed_unit, - pressure=pressure_value, - pressure_unit=pressure_unit, - visibility=visibility_value, - visibility_unit=visibility_unit, - precipitation=precipitation_value, - precipitation_unit=precipitation_unit, - unique_id="very_unique", - ) - ) - - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - await hass.async_block_till_done() - - state = hass.states.get(entity0.entity_id) - - expected_wind_speed = round( - SpeedConverter.convert( - wind_speed_value, wind_speed_unit, UnitOfSpeed.MILES_PER_HOUR - ), - ROUNDING_PRECISION, - ) - expected_temperature = TemperatureConverter.convert( - temperature_value, temperature_unit, UnitOfTemperature.FAHRENHEIT - ) - expected_pressure = round( - PressureConverter.convert(pressure_value, pressure_unit, UnitOfPressure.INHG), - ROUNDING_PRECISION, - ) - expected_visibility = round( - DistanceConverter.convert( - visibility_value, visibility_unit, UnitOfLength.MILES - ), - ROUNDING_PRECISION, - ) - expected_precipitation = round( - DistanceConverter.convert( - precipitation_value, precipitation_unit, UnitOfLength.INCHES - ), - ROUNDING_PRECISION, - ) - - assert state.attributes == { - ATTR_FORECAST: [ - { - ATTR_FORECAST_PRECIPITATION: pytest.approx( - expected_precipitation, rel=0.1 - ), - ATTR_FORECAST_PRESSURE: pytest.approx(expected_pressure, rel=0.1), - ATTR_FORECAST_TEMP: pytest.approx(expected_temperature, rel=0.1), - ATTR_FORECAST_TEMP_LOW: pytest.approx(expected_temperature, rel=0.1), - ATTR_FORECAST_WIND_BEARING: None, - ATTR_FORECAST_WIND_SPEED: pytest.approx(expected_wind_speed, rel=0.1), - } - ], - ATTR_FRIENDLY_NAME: "Test", - ATTR_WEATHER_PRECIPITATION_UNIT: UnitOfLength.INCHES, - ATTR_WEATHER_PRESSURE: pytest.approx(expected_pressure, rel=0.1), - ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, - ATTR_WEATHER_TEMPERATURE: pytest.approx(expected_temperature, rel=0.1), - ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, - ATTR_WEATHER_VISIBILITY: pytest.approx(expected_visibility, rel=0.1), - ATTR_WEATHER_VISIBILITY_UNIT: UnitOfLength.MILES, - ATTR_WEATHER_WIND_SPEED: pytest.approx(expected_wind_speed, rel=0.1), - ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.MILES_PER_HOUR, - } - - async def test_backwards_compatibility_round_temperature(hass: HomeAssistant) -> None: """Test backward compatibility for rounding temperature.""" @@ -1012,47 +780,6 @@ async def test_attr(hass: HomeAssistant) -> None: assert weather._wind_speed_unit == UnitOfSpeed.KILOMETERS_PER_HOUR -async def test_attr_compatibility(hass: HomeAssistant) -> None: - """Test the _attr attributes in compatibility mode.""" - - weather = MockWeatherEntityCompat() - weather.hass = hass - - assert weather.condition == ATTR_CONDITION_SUNNY - assert weather._precipitation_unit == UnitOfLength.MILLIMETERS - assert weather.pressure == 10 - assert weather._pressure_unit == UnitOfPressure.HPA - assert weather.temperature == 20 - assert weather._temperature_unit == UnitOfTemperature.CELSIUS - assert weather.visibility == 30 - assert weather.visibility_unit == UnitOfLength.KILOMETERS - assert weather.wind_speed == 3 - assert weather._wind_speed_unit == UnitOfSpeed.KILOMETERS_PER_HOUR - - forecast_entry = [ - Forecast( - datetime=datetime(2022, 6, 20, 0, 00, 00, tzinfo=dt_util.UTC), - precipitation=1, - temperature=20, - ) - ] - - assert weather.forecast == forecast_entry - - assert weather.state_attributes == { - ATTR_FORECAST: forecast_entry, - ATTR_WEATHER_PRESSURE: 10.0, - ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.HPA, - ATTR_WEATHER_TEMPERATURE: 20.0, - ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.CELSIUS, - ATTR_WEATHER_VISIBILITY: 30.0, - ATTR_WEATHER_VISIBILITY_UNIT: UnitOfLength.KILOMETERS, - ATTR_WEATHER_WIND_SPEED: 3.0 * 3.6, - ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, - ATTR_WEATHER_PRECIPITATION_UNIT: UnitOfLength.MILLIMETERS, - } - - async def test_precision_for_temperature(hass: HomeAssistant) -> None: """Test the precision for temperature.""" @@ -1103,3 +830,206 @@ async def test_forecast_twice_daily_missing_is_daytime( assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} assert not msg["success"] assert msg["type"] == "result" + + +@pytest.mark.parametrize( + ("forecast_type", "supported_features", "extra"), + [ + ("daily", WeatherEntityFeature.FORECAST_DAILY, {}), + ("hourly", WeatherEntityFeature.FORECAST_HOURLY, {}), + ( + "twice_daily", + WeatherEntityFeature.FORECAST_TWICE_DAILY, + {"is_daytime": True}, + ), + ], +) +async def test_get_forecast( + hass: HomeAssistant, + enable_custom_integrations: None, + forecast_type: str, + supported_features: int, + extra: dict[str, Any], +) -> None: + """Test get forecast service.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + supported_features=supported_features, + ) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity0.entity_id, + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "cloud_coverage": None, + "temperature": 38.0, + "templow": 38.0, + "uv_index": None, + "wind_bearing": None, + } + | extra + ], + } + + +async def test_get_forecast_no_forecast( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test get forecast service.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + supported_features=WeatherEntityFeature.FORECAST_DAILY, + ) + + entity0.forecast_list = None + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity0.entity_id, + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [], + } + + +@pytest.mark.parametrize( + ("supported_features", "forecast_types"), + [ + (WeatherEntityFeature.FORECAST_DAILY, ["hourly", "twice_daily"]), + (WeatherEntityFeature.FORECAST_HOURLY, ["daily", "twice_daily"]), + (WeatherEntityFeature.FORECAST_TWICE_DAILY, ["daily", "hourly"]), + ], +) +async def test_get_forecast_unsupported( + hass: HomeAssistant, + enable_custom_integrations: None, + forecast_types: list[str], + supported_features: int, +) -> None: + """Test get forecast service.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + supported_features=supported_features, + ) + + for forecast_type in forecast_types: + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": entity0.entity_id, + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + + +async def test_issue_forecast_deprecated( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the issue is raised on deprecated forecast attributes.""" + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + platform: WeatherPlatform = getattr(hass.components, "test.weather") + caplog.clear() + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockLegacyForecastOnly( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test", "name": "testing"}} + ) + await hass.async_block_till_done() + + assert entity0.state == ATTR_CONDITION_SUNNY + + issues = ir.async_get(hass) + issue = issues.async_get_issue("weather", "deprecated_weather_forecast_test") + assert issue + assert issue.issue_domain == "test" + assert issue.issue_id == "deprecated_weather_forecast_test" + assert issue.translation_placeholders == { + "platform": "test", + "report_issue": "report it to the custom integration author.", + } + + assert ( + "custom_components.test.weather::weather.testing is using a forecast attribute on an instance of WeatherEntity" + in caplog.text + ) + + +async def test_issue_forecast_deprecated_no_logging( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the no issue is raised on deprecated forecast attributes if new methods exist.""" + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + platform: NewWeatherPlatform = getattr(hass.components, "test_weather.weather") + caplog.clear() + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", + entity_id="weather.test", + condition=ATTR_CONDITION_SUNNY, + **kwargs, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test_weather", "name": "test"}} + ) + await hass.async_block_till_done() + + assert entity0.state == ATTR_CONDITION_SUNNY + + assert "Setting up weather.test_weather" in caplog.text + assert ( + "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" + not in caplog.text + ) diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 2864abf58bb..049a38cac1e 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -5,10 +5,7 @@ from datetime import timedelta from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.components.weather import ( - ATTR_CONDITION_SUNNY, - ATTR_FORECAST, -) +from homeassistant.components.weather import ATTR_CONDITION_SUNNY, ATTR_FORECAST from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py index 69adf890584..75a7834f629 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -6,10 +6,7 @@ from homeassistant.components.websocket_api.http import URL from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.typing import ( - MockHAClientWebSocket, - WebSocketGenerator, -) +from tests.typing import MockHAClientWebSocket, WebSocketGenerator @pytest.fixture diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 51bff1af0d7..d5ff879de78 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component -from tests.common import mock_coro from tests.typing import ClientSessionGenerator @@ -72,7 +71,6 @@ async def test_auth_via_msg_incorrect_pass(no_auth_websocket_client) -> None: """Test authenticating.""" with patch( "homeassistant.components.websocket_api.auth.process_wrong_login", - return_value=mock_coro(), ) as mock_process_wrong_login: await no_auth_websocket_client.send_json( {"type": TYPE_AUTH, "api_password": "wrong"} @@ -296,7 +294,6 @@ async def test_auth_sending_unknown_type_disconnects( auth_msg = await ws.receive_json() assert auth_msg["type"] == TYPE_AUTH_REQUIRED - # pylint: disable-next=protected-access await ws._writer._send_frame(b"1" * 130, 0x30) auth_msg = await ws.receive() assert auth_msg.type == WSMsgType.close diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 232362ce96f..96e79a81716 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1,9 +1,10 @@ """Tests for WebSocket API commands.""" +import asyncio from copy import deepcopy import datetime +import logging from unittest.mock import ANY, AsyncMock, Mock, patch -from async_timeout import timeout import pytest import voluptuous as vol @@ -35,6 +36,7 @@ from tests.common import ( ) from tests.typing import ( ClientSessionGenerator, + MockHAClientWebSocket, WebSocketGenerator, ) @@ -500,7 +502,7 @@ async def test_subscribe_unsubscribe_events( hass.bus.async_fire("test_event", {"hello": "world"}) hass.bus.async_fire("ignore_event") - async with timeout(3): + async with asyncio.timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -715,7 +717,7 @@ async def test_subscribe_unsubscribe_events_whitelist( hass.bus.async_fire("themes_updated") - async with timeout(3): + async with asyncio.timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 6 @@ -1228,46 +1230,187 @@ async def test_render_template_manual_entity_ids_no_longer_needed( } +EMPTY_LISTENERS = {"all": False, "entities": [], "domains": [], "time": False} + +ERR_MSG = {"type": "result", "success": False} + +VARIABLE_ERROR_UNDEFINED_FUNC = { + "error": "'my_unknown_func' is undefined", + "level": "ERROR", +} +TEMPLATE_ERROR_UNDEFINED_FUNC = { + "code": "template_error", + "message": "UndefinedError: 'my_unknown_func' is undefined", +} + +VARIABLE_WARNING_UNDEFINED_VAR = { + "error": "'my_unknown_var' is undefined", + "level": "WARNING", +} +TEMPLATE_ERROR_UNDEFINED_VAR = { + "code": "template_error", + "message": "UndefinedError: 'my_unknown_var' is undefined", +} + +TEMPLATE_ERROR_UNDEFINED_FILTER = { + "code": "template_error", + "message": "TemplateAssertionError: No filter named 'unknown_filter'.", +} + + @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, + ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), ], ) async def test_render_template_with_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error.""" + caplog.set_level(logging.INFO) await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template, "strict": True} + { + "id": 5, + "type": "render_template", + "template": template, + "report_errors": True, + } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, + ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), ], ) async def test_render_template_with_timeout_and_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error with a timeout.""" + caplog.set_level(logging.INFO) + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template, + "timeout": 5, + "report_errors": True, + } + ) + + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text + assert "TemplateError" not in caplog.text + + +@pytest.mark.parametrize( + ("template", "expected_events"), + [ + ( + "{{ my_unknown_func() + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}], + ), + ( + "{{ my_unknown_var }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), + ], +) +async def test_render_template_strict_with_timeout_and_error( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], +) -> None: + """Test a template with an error with a timeout.""" + caplog.set_level(logging.INFO) await websocket_client.send_json( { "id": 5, @@ -1278,13 +1421,14 @@ async def test_render_template_with_timeout_and_error( } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text @@ -1302,13 +1446,19 @@ async def test_render_template_error_in_template_code( assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text async def test_render_template_with_delayed_error( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: - """Test a template with an error that only happens after a state change.""" + """Test a template with an error that only happens after a state change. + + In this test report_errors is enabled. + """ + caplog.set_level(logging.INFO) hass.states.async_set("sensor.test", "on") await hass.async_block_till_done() @@ -1321,12 +1471,16 @@ async def test_render_template_with_delayed_error( """ await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template_str} + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": True, + } ) await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1350,13 +1504,74 @@ async def test_render_template_with_delayed_error( msg = await websocket_client.receive_json() assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert msg["type"] == "event" + event = msg["event"] + assert event["error"] == "'None' has no attribute 'state'" + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == {"error": "UndefinedError: 'explode' is undefined"} + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text +async def test_render_template_with_delayed_error_2( + hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture +) -> None: + """Test a template with an error that only happens after a state change. + + In this test report_errors is disabled. + """ + hass.states.async_set("sensor.test", "on") + await hass.async_block_till_done() + + template_str = """ +{% if states.sensor.test.state %} + on +{% else %} + {{ explode + 1 }} +{% endif %} + """ + + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": False, + } + ) + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + hass.states.async_remove("sensor.test") + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == { + "result": "on", + "listeners": { + "all": False, + "domains": [], + "entities": ["sensor.test"], + "time": False, + }, + } + + assert "Template variable warning" in caplog.text + + async def test_render_template_with_timeout( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: @@ -1614,7 +1829,7 @@ async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: hass.bus.async_fire("test_event", {"hello": "world"}, context=context) hass.bus.async_fire("ignore_event") - async with timeout(3): + async with asyncio.timeout(3): msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -1813,7 +2028,7 @@ async def test_execute_script_with_dynamically_validated_action( ws_client = await hass_ws_client(hass) - module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {}) + module_cache = hass.data[loader.DATA_COMPONENTS] module = module_cache["fake_integration.device_action"] module.async_call_action_from_config = AsyncMock() module.async_validate_action_config = AsyncMock( diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index b94df47213e..e69b5629b63 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -18,10 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed -from tests.typing import ( - MockHAClientWebSocket, - WebSocketGenerator, -) +from tests.typing import MockHAClientWebSocket, WebSocketGenerator @pytest.fixture diff --git a/tests/components/wemo/test_config_flow.py b/tests/components/wemo/test_config_flow.py index 71f3a378b74..5cb2b54c9a0 100644 --- a/tests/components/wemo/test_config_flow.py +++ b/tests/components/wemo/test_config_flow.py @@ -2,11 +2,11 @@ from dataclasses import asdict -from homeassistant import data_entry_flow from homeassistant.components.wemo.const import DOMAIN from homeassistant.components.wemo.wemo_device import Options from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, patch @@ -21,7 +21,7 @@ async def test_not_discovered(hass: HomeAssistant) -> None: with patch("homeassistant.components.wemo.config_flow.pywemo") as mock_pywemo: mock_pywemo.discover_devices.return_value = [] result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "no_devices_found" @@ -33,14 +33,14 @@ async def test_options(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], user_input=asdict(options) ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == FlowResultType.CREATE_ENTRY assert Options(**result["data"]) == options @@ -51,7 +51,7 @@ async def test_invalid_options(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "init" # enable_subscription must be True if enable_long_press is True (default). diff --git a/tests/components/wemo/test_device_trigger.py b/tests/components/wemo/test_device_trigger.py index fd5db46e6c6..4ae8dcaddb1 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -17,10 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import ( - async_get_device_automations, - async_mock_service, -) +from tests.common import async_get_device_automations, async_mock_service MOCK_DEVICE_ID = "some-device-id" DATA_MESSAGE = {"message": "service-called"} diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index b715dd4ba72..5c8353fc8bc 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -4,7 +4,6 @@ from dataclasses import asdict from datetime import timedelta from unittest.mock import call, patch -import async_timeout import pytest from pywemo.exceptions import ActionException, PyWeMoException from pywemo.subscribe import EVENT_TYPE_LONG_PRESS @@ -77,7 +76,7 @@ async def test_long_press_event( "testing_params", ) - async with async_timeout.timeout(8): + async with asyncio.timeout(8): await got_event.wait() assert event_data == { @@ -108,7 +107,7 @@ async def test_subscription_callback( pywemo_registry.callbacks[device.wemo.name], device.wemo, "", "" ) - async with async_timeout.timeout(8): + async with asyncio.timeout(8): await got_callback.wait() assert device.last_update_success diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 4e451f46e9b..3155d588e14 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -1,7 +1,8 @@ """Test the Whirlpool Sensor domain.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock +import pytest from whirlpool.washerdryer import MachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL @@ -49,7 +50,7 @@ async def test_dryer_sensor_values( ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( @@ -113,7 +114,7 @@ async def test_washer_sensor_values( ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( @@ -280,7 +281,7 @@ async def test_restore_state( """Test sensor restore state.""" # Home assistant is not running yet hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( @@ -325,6 +326,7 @@ async def test_no_restore_state( assert state.state != "unknown" +@pytest.mark.freeze_time("2022-11-30 00:00:00") async def test_callback( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, @@ -332,7 +334,7 @@ async def test_callback( ) -> None: """Test callback timestamp callback function.""" hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( @@ -377,8 +379,8 @@ async def test_callback( assert state.state == time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_attribute.return_value = "120" + mock_sensor1_api.get_attribute.return_value = "125" callback() state = hass.states.get("sensor.washer_end_time") - newtime = utc_from_timestamp(as_timestamp(time) + 60) + newtime = utc_from_timestamp(as_timestamp(time) + 65) assert state.state == newtime.isoformat() diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index 824801fe44b..bbbdd4e1cbe 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from wled import Device as WLEDDevice @@ -67,7 +68,10 @@ def mock_wled(device_fixture: str) -> Generator[MagicMock, None, None]: @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_wled: MagicMock, ) -> MockConfigEntry: """Set up the WLED integration for testing.""" mock_config_entry.add_to_hass(hass) @@ -75,4 +79,8 @@ async def init_integration( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + # Let some time pass so coordinators can be reliably triggered by bumping + # time by SCAN_INTERVAL + freezer.tick(1) + return mock_config_entry diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py index c1f3165e5bc..92a13baf43c 100644 --- a/tests/components/wled/test_button.py +++ b/tests/components/wled/test_button.py @@ -13,7 +13,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er pytestmark = [ pytest.mark.usefixtures("init_integration"), - pytest.mark.freeze_time("2021-11-04 17:37:00+01:00"), + pytest.mark.freeze_time("2021-11-04 17:36:59+01:00"), ] diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 16aba21392b..678b4a44459 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -27,7 +28,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture @@ -177,6 +177,7 @@ async def test_master_change_state( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" @@ -190,7 +191,8 @@ async def test_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (master := hass.states.get("light.wled_rgb_light_master")) @@ -202,7 +204,8 @@ async def test_dynamically_handle_segments( # Test adding if segment shows up again, including the master entity mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (master := hass.states.get("light.wled_rgb_light_master")) @@ -216,6 +219,7 @@ async def test_dynamically_handle_segments( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_single_segment_behavior( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test the behavior of the integration with a single segment.""" @@ -228,7 +232,8 @@ async def test_single_segment_behavior( # Test segment brightness takes master into account device.state.brightness = 100 device.state.segments[0].brightness = 255 - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("light.wled_rgb_light")) @@ -236,7 +241,8 @@ async def test_single_segment_behavior( # Test segment is off when master is off device.state.on = False - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("light.wled_rgb_light") assert state diff --git a/tests/components/wled/test_number.py b/tests/components/wled/test_number.py index 59f2fb12332..e91ec4f2e66 100644 --- a/tests/components/wled/test_number.py +++ b/tests/components/wled/test_number.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -16,7 +17,6 @@ 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 -import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -113,6 +113,7 @@ async def test_numbers( ) async def test_speed_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, entity_id_segment0: str, entity_id_segment1: str, @@ -130,7 +131,8 @@ async def test_speed_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get(entity_id_segment0)) @@ -140,7 +142,8 @@ async def test_speed_dynamically_handle_segments( # Test remove segment again... mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get(entity_id_segment0)) diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index caf1fa24868..219ec945021 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -17,7 +18,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -125,6 +125,7 @@ async def test_color_palette_state( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_color_palette_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" @@ -137,7 +138,8 @@ async def test_color_palette_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("select.wled_rgb_light_color_palette")) @@ -149,7 +151,8 @@ async def test_color_palette_dynamically_handle_segments( # Test adding if segment shows up again, including the master entity mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("select.wled_rgb_light_color_palette")) @@ -175,13 +178,15 @@ async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> No @pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_preset_active( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test unknown preset returned (when old style/unknown) preset is active.""" # Set device preset state to a random number mock_wled.update.return_value.state.preset = 99 - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("select.wled_rgbw_light_preset")) @@ -191,13 +196,15 @@ async def test_old_style_preset_active( @pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_playlist_active( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test when old style playlist cycle is active.""" # Set device playlist to 0, which meant "cycle" previously. mock_wled.update.return_value.state.playlist = 0 - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (state := hass.states.get("select.wled_rgbw_light_playlist")) diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index 70804e07eb9..40b7783fc04 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -2,6 +2,7 @@ import json from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError @@ -19,7 +20,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, load_fixture @@ -132,6 +132,7 @@ async def test_switch_state( @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) async def test_switch_dynamically_handle_segments( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" @@ -146,7 +147,8 @@ async def test_switch_dynamically_handle_segments( json.loads(load_fixture("wled/rgb.json")) ) - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) @@ -156,7 +158,8 @@ async def test_switch_dynamically_handle_segments( # Test remove segment again... mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 005a63397d9..f87328998e1 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -50,6 +50,15 @@ TEST_CONFIG_WITH_PROVINCE = { "add_holidays": [], "remove_holidays": [], } +TEST_CONFIG_INCORRECT_COUNTRY = { + "name": DEFAULT_NAME, + "country": "ZZ", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} TEST_CONFIG_INCORRECT_PROVINCE = { "name": DEFAULT_NAME, "country": "DE", diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 71dd23c19a3..51280c8d75c 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -17,6 +17,7 @@ from . import ( TEST_CONFIG_EXAMPLE_2, TEST_CONFIG_INCLUDE_HOLIDAY, TEST_CONFIG_INCORRECT_ADD_REMOVE, + TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, TEST_CONFIG_NO_PROVINCE, TEST_CONFIG_NO_STATE, @@ -187,6 +188,21 @@ async def test_setup_day_after_tomorrow( assert state.state == "off" +async def test_setup_faulty_country( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with faulty province.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None + + assert "Selected country ZZ is not valid" in caplog.text + + async def test_setup_faulty_province( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -199,7 +215,7 @@ async def test_setup_faulty_province( state = hass.states.get("binary_sensor.workday_sensor") assert state is None - assert "There is no subdivision" in caplog.text + assert "Selected province ZZ for country DE is not valid" in caplog.text async def test_setup_incorrect_add_remove( diff --git a/tests/components/ws66i/test_media_player.py b/tests/components/ws66i/test_media_player.py index 2cdab824040..c4a10197a34 100644 --- a/tests/components/ws66i/test_media_player.py +++ b/tests/components/ws66i/test_media_player.py @@ -2,6 +2,8 @@ from collections import defaultdict from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, @@ -31,7 +33,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -174,7 +175,7 @@ async def _call_media_player_service(hass, name, data): ) -async def test_update(hass: HomeAssistant) -> None: +async def test_update(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test updating values from ws66i.""" ws66i = MockWs66i() _ = await _setup_ws66i_with_options(hass, ws66i) @@ -191,7 +192,8 @@ async def test_update(hass: HomeAssistant) -> None: ws66i.set_volume(11, MAX_VOL) with patch.object(MockWs66i, "open") as method_call: - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert not method_call.called @@ -203,7 +205,9 @@ async def test_update(hass: HomeAssistant) -> None: assert state.attributes[ATTR_INPUT_SOURCE] == "three" -async def test_failed_update(hass: HomeAssistant) -> None: +async def test_failed_update( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test updating failure from ws66i.""" ws66i = MockWs66i() _ = await _setup_ws66i_with_options(hass, ws66i) @@ -219,23 +223,27 @@ async def test_failed_update(hass: HomeAssistant) -> None: ws66i.set_source(11, 3) ws66i.set_volume(11, MAX_VOL) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # Failed update, close called with patch.object(MockWs66i, "zone_status", return_value=None): - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.is_state(ZONE_1_ID, STATE_UNAVAILABLE) # A connection re-attempt fails with patch.object(MockWs66i, "zone_status", return_value=None): - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # A connection re-attempt succeeds - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # confirm entity is back on @@ -295,14 +303,17 @@ async def test_select_source(hass: HomeAssistant) -> None: assert ws66i.zones[11].source == 3 -async def test_source_select(hass: HomeAssistant) -> None: +async def test_source_select( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test source selection simulated from keypad.""" ws66i = MockWs66i() _ = await _setup_ws66i_with_options(hass, ws66i) ws66i.set_source(11, 5) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(ZONE_1_ID) @@ -341,7 +352,9 @@ async def test_mute_volume(hass: HomeAssistant) -> None: assert ws66i.zones[11].mute -async def test_volume_up_down(hass: HomeAssistant) -> None: +async def test_volume_up_down( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test increasing volume by one.""" ws66i = MockWs66i() _ = await _setup_ws66i(hass, ws66i) @@ -354,26 +367,30 @@ async def test_volume_up_down(hass: HomeAssistant) -> None: await _call_media_player_service( hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} ) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # should not go below zero assert ws66i.zones[11].volume == 0 await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ws66i.zones[11].volume == 1 await _call_media_player_service( hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} ) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ws66i.zones[11].volume == MAX_VOL await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) + freezer.tick(POLL_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() # should not go above 38 (MAX_VOL) assert ws66i.zones[11].volume == MAX_VOL diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 3d12d41ce5e..c326228ec8b 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,4 +1,6 @@ """Tests for the Wyoming integration.""" +import asyncio + from wyoming.info import ( AsrModel, AsrProgram, @@ -7,6 +9,8 @@ from wyoming.info import ( TtsProgram, TtsVoice, TtsVoiceSpeaker, + WakeModel, + WakeProgram, ) TEST_ATTR = Attribution(name="Test", url="http://www.test.com") @@ -49,6 +53,25 @@ TTS_INFO = Info( ) ] ) +WAKE_WORD_INFO = Info( + wake=[ + WakeProgram( + name="Test Wake Word", + description="Test Wake Word", + installed=True, + attribution=TEST_ATTR, + models=[ + WakeModel( + name="Test Model", + description="Test Model", + installed=True, + attribution=TEST_ATTR, + languages=["en-US"], + ) + ], + ) + ] +) EMPTY_INFO = Info() @@ -68,6 +91,7 @@ class MockAsyncTcpClient: async def read_event(self): """Receive.""" + await asyncio.sleep(0) # force context switch return self.responses.pop(0) async def __aenter__(self): diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 6b4e705914f..2c8081908f7 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -8,7 +8,7 @@ from homeassistant.components import stt from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import STT_INFO, TTS_INFO +from . import STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry @@ -52,6 +52,21 @@ def tts_config_entry(hass: HomeAssistant) -> ConfigEntry: return entry +@pytest.fixture +def wake_word_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry( + domain="wyoming", + data={ + "host": "1.2.3.4", + "port": 1234, + }, + title="Test Wake Word", + ) + entry.add_to_hass(hass) + return entry + + @pytest.fixture async def init_wyoming_stt(hass: HomeAssistant, stt_config_entry: ConfigEntry): """Initialize Wyoming STT.""" @@ -72,6 +87,18 @@ async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): await hass.config_entries.async_setup(tts_config_entry.entry_id) +@pytest.fixture +async def init_wyoming_wake_word( + hass: HomeAssistant, wake_word_config_entry: ConfigEntry +): + """Initialize Wyoming Wake Word.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=WAKE_WORD_INFO, + ): + await hass.config_entries.async_setup(wake_word_config_entry.entry_id) + + @pytest.fixture def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: """Get default STT metadata.""" diff --git a/tests/components/wyoming/snapshots/test_wake_word.ambr b/tests/components/wyoming/snapshots/test_wake_word.ambr new file mode 100644 index 00000000000..041112cb6ff --- /dev/null +++ b/tests/components/wyoming/snapshots/test_wake_word.ambr @@ -0,0 +1,13 @@ +# serializer version: 1 +# name: test_streaming_audio + dict({ + 'queued_audio': list([ + tuple( + b'chunk', + 1, + ), + ]), + 'timestamp': 0, + 'ww_id': 'Test Model', + }) +# --- diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py new file mode 100644 index 00000000000..cd156c660a8 --- /dev/null +++ b/tests/components/wyoming/test_wake_word.py @@ -0,0 +1,108 @@ +"""Test stt.""" +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion +from wyoming.asr import Transcript +from wyoming.wake import Detection + +from homeassistant.components import wake_word +from homeassistant.core import HomeAssistant + +from . import MockAsyncTcpClient + + +async def test_support(hass: HomeAssistant, init_wyoming_wake_word) -> None: + """Test supported properties.""" + state = hass.states.get("wake_word.test_wake_word") + assert state is not None + + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + assert entity.supported_wake_words == [ + wake_word.WakeWord(ww_id="Test Model", name="Test Model") + ] + + +async def test_streaming_audio( + hass: HomeAssistant, init_wyoming_wake_word, snapshot: SnapshotAssertion +) -> None: + """Test streaming audio.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk", 0 + + # Delay to force a pending audio chunk + await asyncio.sleep(0.05) + yield b"chunk", 1 + + client_events = [ + Transcript("not a wake word event").event(), + Detection(name="Test Model", timestamp=0).event(), + ] + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + MockAsyncTcpClient(client_events), + ): + result = await entity.async_process_audio_stream(audio_stream()) + + assert result is not None + assert result == snapshot + + +async def test_streaming_audio_connection_lost( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test streaming audio and losing connection.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + # Delay to force a pending audio chunk + await asyncio.sleep(0.05) + yield b"chunk", 1 + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + MockAsyncTcpClient([None]), + ): + result = await entity.async_process_audio_stream(audio_stream()) + + assert result is None + + +async def test_streaming_audio_oserror( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test streaming audio and error raising.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + async def audio_stream(): + yield b"chunk1", 1000 + + mock_client = MockAsyncTcpClient( + [Detection(name="Test Model", timestamp=1000).event()] + ) + + with patch( + "homeassistant.components.wyoming.wake_word.AsyncTcpClient", + mock_client, + ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): + result = await entity.async_process_audio_stream(audio_stream()) + + assert result is None diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 424f6e22b26..32d1fea7f62 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -7,7 +7,7 @@ from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) -from homeassistant.components.xiaomi_ble.const import DOMAIN +from homeassistant.components.xiaomi_ble.const import CONF_SLEEPY_DEVICE, DOMAIN from homeassistant.const import ( ATTR_FRIENDLY_NAME, STATE_OFF, @@ -313,6 +313,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + assert CONF_SLEEPY_DEVICE not in entry.data + async def test_sleepy_device(hass: HomeAssistant) -> None: """Test sleepy device does not go to unavailable after 60 minutes.""" @@ -363,3 +365,66 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + assert entry.data[CONF_SLEEPY_DEVICE] is True + + +async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: + """Test sleepy device does not go to unavailable after 60 minutes and restores state.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:66:E5:67", + ) + 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_bleak( + hass, + make_advertisement( + "A4:C1:38:66:E5:67", + b"@0\xd6\x03$\x19\x10\x01\x00", + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + # Sleepy devices should keep their state over time + assert opening_sensor.state == STATE_ON + + 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() + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + # Sleepy devices should keep their state over time and restore it + assert opening_sensor.state == STATE_ON + + assert entry.data[CONF_SLEEPY_DEVICE] is True diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 7f39228a012..b0ddd99a7c2 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ) from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.components.xiaomi_ble.const import DOMAIN +from homeassistant.components.xiaomi_ble.const import CONF_SLEEPY_DEVICE, DOMAIN from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, @@ -132,6 +132,40 @@ async def test_xiaomi_consumable(hass: HomeAssistant) -> None: await hass.async_block_till_done() +async def test_xiaomi_score(hass: HomeAssistant) -> None: + """Make sure that score sensors are correctly mapped.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="ED:DE:34:3F:48:0C", + data={"bindkey": "1330b99cded13258acc391627e9771f7"}, + ) + 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_bleak( + hass, + make_advertisement( + "ED:DE:34:3F:48:0C", + b"\x48\x58\x06\x08\xc9H\x0e\xf1\x12\x81\x07\x973\xfc\x14\x00\x00VD\xdbA", + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + sensor = hass.states.get("sensor.smart_toothbrush_480c_score") + + sensor_attr = sensor.attributes + assert sensor.state == "83" + assert sensor_attr[ATTR_FRIENDLY_NAME] == "Smart Toothbrush 480C Score" + assert sensor_attr[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_xiaomi_battery_voltage(hass: HomeAssistant) -> None: """Make sure that battery voltage sensors are correctly mapped.""" entry = MockConfigEntry( @@ -679,7 +713,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: async def test_sleepy_device(hass: HomeAssistant) -> None: - """Test normal device goes to unavailable after 60 minutes.""" + """Test sleepy devices stay available.""" start_monotonic = time.monotonic() entry = MockConfigEntry( @@ -725,3 +759,64 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: + """Test sleepy devices stay available.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="50:FB:19:1B:B5:DC", + ) + 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_bleak(hass, MISCALE_V1_SERVICE_INFO) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + mass_non_stabilized_sensor = hass.states.get( + "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + ) + assert mass_non_stabilized_sensor.state == "86.55" + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + mass_non_stabilized_sensor = hass.states.get( + "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + ) + + # Sleepy devices should keep their state over time + assert mass_non_stabilized_sensor.state == "86.55" + + 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() + + mass_non_stabilized_sensor = hass.states.get( + "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + ) + + # Sleepy devices should keep their state over time and restore it + assert mass_non_stabilized_sensor.state == "86.55" + + assert entry.data[CONF_SLEEPY_DEVICE] is True diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py new file mode 100644 index 00000000000..c3f5fcf74b8 --- /dev/null +++ b/tests/components/yale_smart_alarm/conftest.py @@ -0,0 +1,64 @@ +"""Fixtures for the Yale Smart Living integration.""" +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import Mock, patch + +import pytest +from yalesmartalarmclient.const import YALE_STATE_ARM_FULL + +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +ENTRY_CONFIG = { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "1", +} +OPTIONS_CONFIG = {"lock_code_digits": 6} + + +@pytest.fixture +async def load_config_entry( + hass: HomeAssistant, load_json: dict[str, Any] +) -> tuple[MockConfigEntry, Mock]: + """Set up the Yale Smart Living integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="username", + version=1, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + client.auth = None + client.lock_api = None + client.get_all.return_value = load_json + client.get_armed_status.return_value = YALE_STATE_ARM_FULL + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return (config_entry, client) + + +@pytest.fixture(name="load_json", scope="session") +def load_json_from_fixture() -> dict[str, Any]: + """Load fixture with json data and return.""" + + data_fixture = load_fixture("get_all.json", "yale_smart_alarm") + json_data: dict[str, Any] = json.loads(data_fixture) + return json_data diff --git a/tests/components/yale_smart_alarm/fixtures/get_all.json b/tests/components/yale_smart_alarm/fixtures/get_all.json new file mode 100644 index 00000000000..0878cbf9c6a --- /dev/null +++ b/tests/components/yale_smart_alarm/fixtures/get_all.json @@ -0,0 +1,1227 @@ +{ + "DEVICES": [ + { + "area": "1", + "no": "1", + "rf": null, + "address": "1111", + "type": "device_type.door_lock", + "name": "Device1", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:01", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "35", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "1111", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "2", + "rf": null, + "address": "2222", + "type": "device_type.door_lock", + "name": "Device2", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:02", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "2222", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "3", + "rf": null, + "address": "3333", + "type": "device_type.door_lock", + "name": "Device3", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:03", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "3333", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "4", + "rf": null, + "address": "RF4", + "type": "device_type.door_contact", + "name": "Device4", + "status1": "device_status.dc_close", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:04", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "4444", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_close"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "5", + "rf": null, + "address": "RF5", + "type": "device_type.door_contact", + "name": "Device5", + "status1": "device_status.dc_open", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:05", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "5555", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_open"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "6", + "rf": null, + "address": "RF6", + "type": "device_type.door_contact", + "name": "Device6", + "status1": "unknwon", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "REDACTED", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "6666", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "7", + "rf": null, + "address": "7777", + "type": "device_type.door_lock", + "name": "Device7", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:07", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "36", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "7777", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "8", + "rf": null, + "address": "8888", + "type": "device_type.door_lock", + "name": "Device8", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:08", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "4", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "8888", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.unlock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "9", + "rf": null, + "address": "9999", + "type": "device_type.door_lock", + "name": "Device9", + "status1": "device_status.error", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:09", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "10", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "9999", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.error"], + "trigger_by_zone": [] + } + ], + "MODE": [ + { + "area": "1", + "mode": "disarm" + } + ], + "STATUS": { + "acfail": "main.normal", + "battery": "main.normal", + "tamper": "main.normal", + "jam": "main.normal", + "rssi": "1", + "gsm_rssi": "0", + "imei": "", + "imsi": "" + }, + "CYCLE": { + "model": [ + { + "area": "1", + "mode": "disarm" + } + ], + "panel_status": { + "warning_snd_mute": "0" + }, + "device_status": [ + { + "area": "1", + "no": "1", + "rf": null, + "address": "1111", + "type": "device_type.door_lock", + "name": "Device1", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:01", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "35", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "1111", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "2", + "rf": null, + "address": "2222", + "type": "device_type.door_lock", + "name": "Device2", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:02", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "2222", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "3", + "rf": null, + "address": "3333", + "type": "device_type.door_lock", + "name": "Device3", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:03", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": null, + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "3333", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "4", + "rf": null, + "address": "RF4", + "type": "device_type.door_contact", + "name": "Device4", + "status1": "device_status.dc_close", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:04", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "4444", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_close"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "5", + "rf": null, + "address": "RF5", + "type": "device_type.door_contact", + "name": "Device5", + "status1": "device_status.dc_open", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "00:00:00:00:05", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "5555", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": ["device_status.dc_open"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "6", + "rf": null, + "address": "RF6", + "type": "device_type.door_contact", + "name": "Device6", + "status1": "unknwon", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "0", + "mac": "REDACTED", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "", + "minigw_syncing": "", + "minigw_configuration_data": "", + "minigw_product_data": "", + "minigw_lock_status": "", + "minigw_number_of_credentials_supported": "", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "6666", + "status_temp_format": "C", + "type_no": "4", + "device_group": "000", + "status_fault": [], + "status_open": [], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "7", + "rf": null, + "address": "7777", + "type": "device_type.door_lock", + "name": "Device7", + "status1": "device_status.lock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:07", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "36", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "7777", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.lock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "8", + "rf": null, + "address": "8888", + "type": "device_type.door_lock", + "name": "Device8", + "status1": "device_status.unlock", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:08", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "4", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "8888", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.unlock"], + "trigger_by_zone": [] + }, + { + "area": "1", + "no": "9", + "rf": null, + "address": "9999", + "type": "device_type.door_lock", + "name": "Device9", + "status1": "device_status.error", + "status2": null, + "status_switch": null, + "status_power": null, + "status_temp": null, + "status_humi": null, + "status_dim_level": null, + "status_lux": "", + "status_hue": null, + "status_saturation": null, + "rssi": "9", + "mac": "00:00:00:00:09", + "scene_trigger": "0", + "status_total_energy": null, + "device_id2": "", + "extension": null, + "minigw_protocol": "DM", + "minigw_syncing": "0", + "minigw_configuration_data": "02FF000001000000000000000000001E000100", + "minigw_product_data": "21020120", + "minigw_lock_status": "10", + "minigw_number_of_credentials_supported": "10", + "sresp_button_3": null, + "sresp_button_1": null, + "sresp_button_2": null, + "sresp_button_4": null, + "ipcam_trigger_by_zone1": null, + "ipcam_trigger_by_zone2": null, + "ipcam_trigger_by_zone3": null, + "ipcam_trigger_by_zone4": null, + "scene_restore": null, + "thermo_mode": null, + "thermo_setpoint": null, + "thermo_c_setpoint": null, + "thermo_setpoint_away": null, + "thermo_c_setpoint_away": null, + "thermo_fan_mode": null, + "thermo_schd_setting": null, + "group_id": null, + "group_name": null, + "bypass": "0", + "device_id": "9999", + "status_temp_format": "C", + "type_no": "72", + "device_group": "002", + "status_fault": [], + "status_open": ["device_status.error"], + "trigger_by_zone": [] + } + ], + "capture_latest": null, + "report_event_latest": { + "utc_event_time": null, + "time": "1692271914", + "report_id": "1027299996", + "id": "9999", + "event_time": null, + "cid_code": "1807" + }, + "alarm_event_latest": null + }, + "ONLINE": "online", + "HISTORY": [ + { + "report_id": "1027299996", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:31:54", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027299889", + "cid": "18180201101", + "event_type": "1802", + "user": 101, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:31:43", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027299587", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:31:11", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027296099", + "cid": "18180101001", + "event_type": "1801", + "user": 1, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 11:24:52", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027273782", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 10:43:21", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027273230", + "cid": "18180201101", + "event_type": "1802", + "user": 101, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 10:42:09", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027100172", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 05:28:57", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027099978", + "cid": "18180101001", + "event_type": "1801", + "user": 1, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/17 05:28:39", + "status_temp_format": "C", + "cid_source": "DEVICE" + }, + { + "report_id": "1027093266", + "cid": "18160200000", + "event_type": "1602", + "user": "", + "area": 0, + "zone": 0, + "name": "", + "type": "", + "event_time": null, + "time": "2023/08/17 05:17:12", + "status_temp_format": "C", + "cid_source": "SYSTEM" + }, + { + "report_id": "1026912623", + "cid": "18180701000", + "event_type": "1807", + "user": 0, + "area": 1, + "zone": 1, + "name": "Device1", + "type": "device_type.door_lock", + "event_time": null, + "time": "2023/08/16 20:29:36", + "status_temp_format": "C", + "cid_source": "DEVICE" + } + ], + "PANEL INFO": { + "mac": "00:00:00:00:10", + "report_account": "username", + "xml_version": "2", + "version": "MINIGW-MZ-1_G 1.0.1.29A,,4.1.2.6.2,00:1D:94:0B:5E:A7,10111112,ML_yamga", + "net_version": "MINIGW-MZ-1_G 1.0.1.29A", + "rf51_version": "", + "zb_version": "4.1.2.6.2", + "zw_version": "", + "SMS_Balance": "50", + "voice_balance": "0", + "name": "", + "contact": "", + "mail_address": "username@fake.com", + "phone": "UK-01902364606 / Sweden-0770373710 / Demark-89887818 / Norway-81569036", + "service_time": "UK - Mon to Fri 8:30 til 17:30 / Scandinavia - Mon to Fri 8:00 til 20:00, Sat to Sun 10:00 til 15:00", + "dealer_name": "Poland" + }, + "AUTH CHECK": { + "user_id": "username", + "id": "username", + "mail_address": "username@fake.com", + "mac": "00:00:00:00:20", + "is_auth": "1", + "master": "1", + "first_login": "1", + "name": "Device1", + "token_time": "2023-08-17 16:19:20", + "agent": false, + "xml_version": "2", + "dealer_id": "605", + "dealer_group": "yale" + } +} diff --git a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ae720a611e3 --- /dev/null +++ b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr @@ -0,0 +1,1312 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'AUTH CHECK': dict({ + 'agent': False, + 'dealer_group': 'yale', + 'dealer_id': '605', + 'first_login': '1', + 'id': '**REDACTED**', + 'is_auth': '1', + 'mac': '**REDACTED**', + 'mail_address': '**REDACTED**', + 'master': '1', + 'name': '**REDACTED**', + 'token_time': '2023-08-17 16:19:20', + 'user_id': '**REDACTED**', + 'xml_version': '2', + }), + 'CYCLE': dict({ + 'alarm_event_latest': None, + 'capture_latest': None, + 'device_status': list([ + dict({ + '_state': 'locked', + '_state2': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '35', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '1', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unlocked', + '_state2': 'unknown', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '2', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'locked', + '_state2': 'unknown', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '3', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '4', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_close', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_close', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'open', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '5', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_open', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_open', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'unavailable', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '6', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'unknwon', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'unlocked', + '_state2': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '36', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '7', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unlocked', + '_state2': 'open', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '4', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.unlock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unavailable', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '10', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '9', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.error', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.error', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + ]), + 'model': list([ + dict({ + 'area': '1', + 'mode': 'disarm', + }), + ]), + 'panel_status': dict({ + 'warning_snd_mute': '0', + }), + 'report_event_latest': dict({ + 'cid_code': '1807', + 'event_time': None, + 'id': '**REDACTED**', + 'report_id': '1027299996', + 'time': '1692271914', + 'utc_event_time': None, + }), + }), + 'DEVICES': list([ + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '35', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '1', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '2', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '3', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '4', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_close', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_close', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '5', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_open', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_open', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '6', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'unknwon', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '36', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '7', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '4', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.unlock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '10', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '9', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.error', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.error', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + ]), + 'HISTORY': list([ + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027299996', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:54', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180201101', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1802', + 'name': '**REDACTED**', + 'report_id': '1027299889', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:43', + 'type': 'device_type.door_lock', + 'user': 101, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027299587', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:11', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180101001', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1801', + 'name': '**REDACTED**', + 'report_id': '1027296099', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:24:52', + 'type': 'device_type.door_lock', + 'user': 1, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027273782', + 'status_temp_format': 'C', + 'time': '2023/08/17 10:43:21', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180201101', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1802', + 'name': '**REDACTED**', + 'report_id': '1027273230', + 'status_temp_format': 'C', + 'time': '2023/08/17 10:42:09', + 'type': 'device_type.door_lock', + 'user': 101, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027100172', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:28:57', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180101001', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1801', + 'name': '**REDACTED**', + 'report_id': '1027099978', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:28:39', + 'type': 'device_type.door_lock', + 'user': 1, + 'zone': 1, + }), + dict({ + 'area': 0, + 'cid': '18160200000', + 'cid_source': 'SYSTEM', + 'event_time': None, + 'event_type': '1602', + 'name': '', + 'report_id': '1027093266', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:17:12', + 'type': '', + 'user': '', + 'zone': 0, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1026912623', + 'status_temp_format': 'C', + 'time': '2023/08/16 20:29:36', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + ]), + 'MODE': list([ + dict({ + 'area': '1', + 'mode': 'disarm', + }), + ]), + 'ONLINE': 'online', + 'PANEL INFO': dict({ + 'SMS_Balance': '50', + 'contact': '', + 'dealer_name': 'Poland', + 'mac': '**REDACTED**', + 'mail_address': '**REDACTED**', + 'name': '', + 'net_version': 'MINIGW-MZ-1_G 1.0.1.29A', + 'phone': 'UK-01902364606 / Sweden-0770373710 / Demark-89887818 / Norway-81569036', + 'report_account': '**REDACTED**', + 'rf51_version': '', + 'service_time': 'UK - Mon to Fri 8:30 til 17:30 / Scandinavia - Mon to Fri 8:00 til 20:00, Sat to Sun 10:00 til 15:00', + 'version': 'MINIGW-MZ-1_G 1.0.1.29A,,4.1.2.6.2,00:1D:94:0B:5E:A7,10111112,ML_yamga', + 'voice_balance': '0', + 'xml_version': '2', + 'zb_version': '4.1.2.6.2', + 'zw_version': '', + }), + 'STATUS': dict({ + 'acfail': 'main.normal', + 'battery': 'main.normal', + 'gsm_rssi': '0', + 'imei': '', + 'imsi': '', + 'jam': 'main.normal', + 'rssi': '1', + 'tamper': 'main.normal', + }), + }) +# --- diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index 4553a120060..90c0b78baf5 100644 --- a/tests/components/yale_smart_alarm/test_config_flow.py +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -121,6 +121,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: "name": "Yale Smart Alarm", "area_id": "1", }, + version=2, ) entry.add_to_hass(hass) @@ -187,6 +188,7 @@ async def test_reauth_flow_error( "name": "Yale Smart Alarm", "area_id": "1", }, + version=2, ) entry.add_to_hass(hass) @@ -248,11 +250,20 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, unique_id="test-username", - data={}, + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, ) entry.add_to_hass(hass) with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value=True, + ), patch( "homeassistant.components.yale_smart_alarm.async_setup_entry", return_value=True, ): @@ -266,48 +277,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"code": "123456", "lock_code_digits": 6}, + user_input={"lock_code_digits": 6}, ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"code": "123456", "lock_code_digits": 6} - - -async def test_options_flow_format_mismatch(hass: HomeAssistant) -> None: - """Test options config flow with a code format mismatch error.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test-username", - data={}, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.yale_smart_alarm.async_setup_entry", - return_value=True, - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] == {} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"code": "123", "lock_code_digits": 6}, - ) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] == {"base": "code_format_mismatch"} - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"code": "123456", "lock_code_digits": 6}, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"code": "123456", "lock_code_digits": 6} + assert result["data"] == {"lock_code_digits": 6} diff --git a/tests/components/yale_smart_alarm/test_coordinator.py b/tests/components/yale_smart_alarm/test_coordinator.py new file mode 100644 index 00000000000..9ee09e9c0f2 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_coordinator.py @@ -0,0 +1,123 @@ +"""The test for the sensibo coordinator.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from unittest.mock import Mock, patch + +import pytest +from yalesmartalarmclient.const import YALE_STATE_ARM_FULL +from yalesmartalarmclient.exceptions import AuthenticationError, UnknownError + +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import STATE_ALARM_ARMED_AWAY, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .conftest import ENTRY_CONFIG, OPTIONS_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + "p_error", + [ + AuthenticationError(), + UnknownError(), + ConnectionError("Could not connect"), + TimeoutError(), + ], +) +async def test_coordinator_setup_errors( + hass: HomeAssistant, + load_json: dict[str, Any], + p_error: Exception, +) -> None: + """Test the Yale Smart Living coordinator with errors.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="username", + version=1, + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", + autospec=True, + ) as mock_client_class: + mock_client_class.side_effect = p_error + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert not state + + +async def test_coordinator_setup_and_update_errors( + hass: HomeAssistant, + load_config_entry: tuple[MockConfigEntry, Mock], + load_json: dict[str, Any], +) -> None: + """Test the Yale Smart Living coordinator with errors.""" + + client = load_config_entry[1] + + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_ALARM_ARMED_AWAY + client.reset_mock() + + client.get_all.side_effect = ConnectionError("Could not connect") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = ConnectionError("Could not connect") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = TimeoutError("Could not connect") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = UnknownError("info") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE + client.reset_mock() + + client.get_all.side_effect = None + client.get_all.return_value = load_json + client.get_armed_status.return_value = YALE_STATE_ARM_FULL + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_ALARM_ARMED_AWAY + client.reset_mock() + + client.get_all.side_effect = AuthenticationError("Can not authenticate") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + client.get_all.assert_called_once() + state = hass.states.get("alarm_control_panel.yale_smart_alarm") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/yale_smart_alarm/test_diagnostics.py b/tests/components/yale_smart_alarm/test_diagnostics.py new file mode 100644 index 00000000000..dc4c5e8c8d7 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Test Yale Smart Living diagnostics.""" +from __future__ import annotations + +from unittest.mock import Mock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + load_config_entry: tuple[MockConfigEntry, Mock], + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + entry = load_config_entry[0] + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag == snapshot diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index 2df37a72b70..593b9a7a9d0 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Yale Access Bluetooth config flow.""" import asyncio -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from bleak import BleakError import pytest @@ -32,6 +32,7 @@ def _get_mock_push_lock(): """Return a mock PushLock.""" mock_push_lock = Mock() mock_push_lock.start = AsyncMock() + mock_push_lock.start.return_value = MagicMock() mock_push_lock.wait_for_first_update = AsyncMock() mock_push_lock.stop = AsyncMock() mock_push_lock.lock_state = LockState( diff --git a/tests/components/yardian/conftest.py b/tests/components/yardian/conftest.py new file mode 100644 index 00000000000..d4f289c4242 --- /dev/null +++ b/tests/components/yardian/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Yardian 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.yardian.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/yardian/test_config_flow.py b/tests/components/yardian/test_config_flow.py new file mode 100644 index 00000000000..5f1fcc940cc --- /dev/null +++ b/tests/components/yardian/test_config_flow.py @@ -0,0 +1,188 @@ +"""Test the Yardian config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest +from pyyardian import NetworkException, NotAuthorizedException + +from homeassistant import config_entries +from homeassistant.components.yardian.const import DOMAIN, PRODUCT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + 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.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == PRODUCT_NAME + assert result2["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=NotAuthorizedException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=NetworkException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_uncategorized_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle uncategorized error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + # Should be recoverable after hits error + with patch( + "homeassistant.components.yardian.config_flow.AsyncYardianClient.fetch_device_info", + return_value={"name": "fake_name", "yid": "fake_yid"}, + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "fake_host", + "access_token": "fake_token", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == PRODUCT_NAME + assert result3["data"] == { + "host": "fake_host", + "access_token": "fake_token", + "name": "fake_name", + "yid": "fake_yid", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index 906dbf50ace..b439ce04c25 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -618,3 +618,28 @@ async def test_async_setup_with_discovery_not_working(hass: HomeAssistant) -> No assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get("light.yeelight_color_0x15243f").state == STATE_ON + + +async def test_async_setup_retries_with_wrong_device( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the config entry enters a retry state with the wrong device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_ID: "0x0000000000999999"}, + options={}, + unique_id="0x0000000000999999", + ) + config_entry.add_to_hass(hass) + + with _patch_discovery(), _patch_discovery_timeout(), _patch_discovery_interval(), patch( + f"{MODULE}.AsyncBulb", return_value=_mocked_bulb() + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + "Unexpected device found at 192.168.1.239; expected 0x0000000000999999, " + "found 0x000000000015243f; Retrying in background" + ) in caplog.text diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 6161f66fdd1..47dbd54baa9 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -181,7 +181,7 @@ async def test_services(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - await hass.services.async_call(domain, service, data, blocking=True) if payload is None: mocked_method.assert_called_once() - elif type(payload) == list: + elif isinstance(payload, list): mocked_method.assert_called_once_with(*payload) else: mocked_method.assert_called_once_with(**payload) diff --git a/tests/components/youtube/fixtures/get_no_subscriptions.json b/tests/components/youtube/fixtures/get_no_subscriptions.json new file mode 100644 index 00000000000..77a64503fc0 --- /dev/null +++ b/tests/components/youtube/fixtures/get_no_subscriptions.json @@ -0,0 +1,10 @@ +{ + "kind": "youtube#SubscriptionListResponse", + "etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI", + "nextPageToken": "CAEQAA", + "pageInfo": { + "totalResults": 0, + "resultsPerPage": 1 + }, + "items": [] +} diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 97875004d11..c4aacc9603d 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -121,6 +121,46 @@ async def test_flow_abort_without_channel( assert result["reason"] == "no_channel" +async def test_flow_abort_without_subscriptions( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check abort flow if user has no subscriptions.""" + result = await hass.config_entries.flow.async_init( + "youtube", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + service = MockYouTube(subscriptions_fixture="youtube/get_no_subscriptions.json") + with patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ), patch( + "homeassistant.components.youtube.config_flow.YouTube", return_value=service + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_subscriptions" + + async def test_flow_http_error( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 79c319398f0..db1da3721ee 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -87,10 +87,7 @@ def update_attribute_cache(cluster): def get_zha_gateway(hass): """Return ZHA gateway from hass.data.""" - try: - return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] - except KeyError: - return None + return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] def make_attribute(attrid, value, status=0): @@ -167,15 +164,11 @@ def find_entity_ids(domain, zha_device, hass): def async_find_group_entity_id(hass, domain, group): """Find the group entity id under test.""" - entity_id = ( - f"{domain}.fakemanufacturer_fakemodel_{group.name.lower().replace(' ', '_')}" - ) + entity_id = f"{domain}.coordinator_manufacturer_coordinator_model_{group.name.lower().replace(' ', '_')}" entity_ids = hass.states.async_entity_ids(domain) - - if entity_id in entity_ids: - return entity_id - return None + assert entity_id in entity_ids + return entity_id async def async_enable_traffic(hass, zha_devices, enabled=True): diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index e3a12703640..4778f3216da 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -1,5 +1,5 @@ """Test configuration for the ZHA component.""" -from collections.abc import Callable +from collections.abc import Callable, Generator import itertools import time from unittest.mock import AsyncMock, MagicMock, patch @@ -15,6 +15,9 @@ import zigpy.group import zigpy.profiles import zigpy.quirks import zigpy.types +import zigpy.util +from zigpy.zcl.clusters.general import Basic, Groups +from zigpy.zcl.foundation import Status import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.const as zha_const @@ -30,6 +33,17 @@ FIXTURE_GRP_ID = 0x1001 FIXTURE_GRP_NAME = "fixture group" +@pytest.fixture(scope="session", autouse=True) +def disable_request_retry_delay(): + """Disable ZHA request retrying delay to speed up failures.""" + + with patch( + "homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", + zigpy.util.retryable_request(tries=3, delay=0), + ): + yield + + @pytest.fixture(scope="session", autouse=True) def globally_load_quirks(): """Load quirks automatically so that ZHA tests run deterministically in isolation. @@ -104,6 +118,9 @@ def zigpy_app_controller(): { zigpy.config.CONF_DATABASE: None, zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/null"}, + zigpy.config.CONF_STARTUP_ENERGY_SCAN: False, + zigpy.config.CONF_NWK_BACKUP_ENABLED: False, + zigpy.config.CONF_TOPO_SCAN_ENABLED: False, } ) @@ -116,17 +133,32 @@ def zigpy_app_controller(): app.state.network_info.channel = 15 app.state.network_info.network_key.key = zigpy.types.KeyData(range(16)) - with patch("zigpy.device.Device.request"), patch.object( - app, "permit", autospec=True - ), patch.object(app, "permit_with_key", autospec=True): + # Create a fake coordinator device + dev = app.add_device(nwk=app.state.node_info.nwk, ieee=app.state.node_info.ieee) + dev.node_desc = zdo_t.NodeDescriptor() + dev.node_desc.logical_type = zdo_t.LogicalType.Coordinator + dev.manufacturer = "Coordinator Manufacturer" + dev.model = "Coordinator Model" + + ep = dev.add_endpoint(1) + ep.add_input_cluster(Basic.cluster_id) + ep.add_input_cluster(Groups.cluster_id) + + with patch( + "zigpy.device.Device.request", return_value=[Status.SUCCESS] + ), patch.object(app, "permit", autospec=True), patch.object( + app, "startup", wraps=app.startup + ), patch.object( + app, "permit_with_key", autospec=True + ): yield app @pytest.fixture(name="config_entry") -async def config_entry_fixture(hass): +async def config_entry_fixture(hass) -> MockConfigEntry: """Fixture representing a config entry.""" - entry = MockConfigEntry( - version=2, + return MockConfigEntry( + version=3, domain=zha_const.DOMAIN, data={ zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0"}, @@ -146,23 +178,30 @@ async def config_entry_fixture(hass): } }, ) - entry.add_to_hass(hass) - return entry @pytest.fixture -def setup_zha(hass, config_entry, zigpy_app_controller): +def mock_zigpy_connect( + zigpy_app_controller: ControllerApplication, +) -> Generator[ControllerApplication, None, None]: + """Patch the zigpy radio connection with our mock application.""" + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_app: + yield mock_app + + +@pytest.fixture +def setup_zha(hass, config_entry: MockConfigEntry, mock_zigpy_connect): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} - p1 = patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ) - async def _setup(config=None): + config_entry.add_to_hass(hass) config = config or {} - with p1: + + with mock_zigpy_connect: status = await async_setup_component( hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}} ) diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 85f85cc0437..c2cb16efcc8 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -71,7 +71,7 @@ async def test_async_get_network_settings_missing( await setup_zha() gateway = api._get_gateway(hass) - await zha.async_unload_entry(hass, gateway.config_entry) + await gateway.config_entry.async_unload(hass) # Network settings were never loaded for whatever reason zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index 2a2fbc92ace..cc0b5079fd3 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -30,13 +30,12 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import mock_coro - @pytest.fixture(autouse=True) def button_platform_only(): @@ -151,7 +150,7 @@ async def test_button(hass: HomeAssistant, contact_sensor) -> None: with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): await hass.services.async_call( DOMAIN, @@ -191,7 +190,7 @@ async def test_frost_unlock(hass: HomeAssistant, tuya_water_valve) -> None: with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): await hass.services.async_call( DOMAIN, @@ -200,8 +199,9 @@ async def test_frost_unlock(hass: HomeAssistant, tuya_water_valve) -> None: blocking=True, ) await hass.async_block_till_done() - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"frost_lock_reset": 0}) + assert cluster.write_attributes.mock_calls == [ + call({"frost_lock_reset": 0}, manufacturer=None) + ] state = hass.states.get(entity_id) assert state @@ -210,11 +210,17 @@ async def test_frost_unlock(hass: HomeAssistant, tuya_water_valve) -> None: cluster.write_attributes.reset_mock() cluster.write_attributes.side_effect = ZigbeeException - await hass.services.async_call( - DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"frost_lock_reset": 0}) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # There are three retries + assert cluster.write_attributes.mock_calls == [ + call({"frost_lock_reset": 0}, manufacturer=None), + call({"frost_lock_reset": 0}, manufacturer=None), + call({"frost_lock_reset": 0}, manufacturer=None), + ] diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index fd8bcaa1085..145aba799ca 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -1,8 +1,10 @@ """Test ZHA climate.""" -from unittest.mock import patch +from typing import Literal +from unittest.mock import call, patch import pytest import zhaquirks.sinope.thermostat +from zhaquirks.sinope.thermostat import SinopeTechnologiesThermostatCluster import zhaquirks.tuya.ts0601_trv import zigpy.profiles import zigpy.types @@ -37,7 +39,12 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.zha.climate import HVAC_MODE_2_SYSTEM, SEQ_OF_OPERATION -from homeassistant.components.zha.core.const import PRESET_COMPLEX, PRESET_SCHEDULE +from homeassistant.components.zha.core.const import ( + PRESET_COMPLEX, + PRESET_SCHEDULE, + PRESET_TEMP_MANUAL, +) +from homeassistant.components.zha.core.device import ZHADevice from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -45,6 +52,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -129,6 +137,23 @@ CLIMATE_MOES = { } } +CLIMATE_BECA = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SMART_PLUG, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Groups.cluster_id, + zigpy.zcl.clusters.general.Scenes.cluster_id, + 61148, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Time.cluster_id, + zigpy.zcl.clusters.general.Ota.cluster_id, + ], + } +} + CLIMATE_ZONNSMART = { 1: { SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, @@ -146,6 +171,7 @@ CLIMATE_ZONNSMART = { MANUF_SINOPE = "Sinope Technologies" MANUF_ZEN = "Zen Within" MANUF_MOES = "_TZE200_ckud7u2l" +MANUF_BECA = "_TZE200_b6wax7g0" MANUF_ZONNSMART = "_TZE200_hue3yfsn" ZCL_ATTR_PLUG = { @@ -257,6 +283,17 @@ async def device_climate_moes(device_climate_mock): ) +@pytest.fixture +async def device_climate_beca(device_climate_mock) -> ZHADevice: + """Beca thermostat.""" + + return await device_climate_mock( + CLIMATE_BECA, + manuf=MANUF_BECA, + quirk=zhaquirks.tuya.ts0601_trv.MoesHY368_Type1new, + ) + + @pytest.fixture async def device_climate_zonnsmart(device_climate_mock): """ZONNSMART thermostat.""" @@ -553,7 +590,11 @@ async def test_hvac_modes( ), ) async def test_target_temperature( - hass: HomeAssistant, device_climate_mock, sys_mode, preset, target_temp + hass: HomeAssistant, + device_climate_mock, + sys_mode: Thermostat.SystemMode, + preset: Literal[PRESET_AWAY] | None, + target_temp: int, ) -> None: """Test target temperature property.""" @@ -720,15 +761,23 @@ async def test_preset_setting(hass: HomeAssistant, device_climate_sinope) -> Non # unsuccessful occupancy change thrm_cluster.write_attributes.return_value = [ - zcl_f.WriteAttributesResponse.deserialize(b"\x01\x00\x00")[0] + zcl_f.WriteAttributesResponse( + [ + zcl_f.WriteAttributesStatusRecord( + status=zcl_f.Status.FAILURE, + attrid=SinopeTechnologiesThermostatCluster.AttributeDefs.set_occupancy.id, + ) + ] + ) ] - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -738,7 +787,9 @@ async def test_preset_setting(hass: HomeAssistant, device_climate_sinope) -> Non # successful occupancy change thrm_cluster.write_attributes.reset_mock() thrm_cluster.write_attributes.return_value = [ - zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] + zcl_f.WriteAttributesResponse( + [zcl_f.WriteAttributesStatusRecord(status=zcl_f.Status.SUCCESS)] + ) ] await hass.services.async_call( CLIMATE_DOMAIN, @@ -755,14 +806,23 @@ async def test_preset_setting(hass: HomeAssistant, device_climate_sinope) -> Non # unsuccessful occupancy change thrm_cluster.write_attributes.reset_mock() thrm_cluster.write_attributes.return_value = [ - zcl_f.WriteAttributesResponse.deserialize(b"\x01\x01\x01")[0] + zcl_f.WriteAttributesResponse( + [ + zcl_f.WriteAttributesStatusRecord( + status=zcl_f.Status.FAILURE, + attrid=SinopeTechnologiesThermostatCluster.AttributeDefs.set_occupancy.id, + ) + ] + ) ] - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY @@ -772,7 +832,9 @@ async def test_preset_setting(hass: HomeAssistant, device_climate_sinope) -> Non # successful occupancy change thrm_cluster.write_attributes.reset_mock() thrm_cluster.write_attributes.return_value = [ - zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] + zcl_f.WriteAttributesResponse( + [zcl_f.WriteAttributesStatusRecord(status=zcl_f.Status.SUCCESS)] + ) ] await hass.services.async_call( CLIMATE_DOMAIN, @@ -1386,6 +1448,49 @@ async def test_set_moes_operation_mode( assert state.attributes[ATTR_PRESET_MODE] == PRESET_COMPLEX +@pytest.mark.parametrize( + ("preset_attr", "preset_mode"), + [ + (0, PRESET_AWAY), + (1, PRESET_SCHEDULE), + # (2, PRESET_NONE), # TODO: why does this not work? + (4, PRESET_ECO), + (5, PRESET_BOOST), + (7, PRESET_TEMP_MANUAL), + ], +) +async def test_beca_operation_mode_update( + hass: HomeAssistant, + device_climate_beca: ZHADevice, + preset_attr: int, + preset_mode: str, +) -> None: + """Test beca trv operation mode attribute update.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_beca, hass) + thrm_cluster = device_climate_beca.device.endpoints[1].thermostat + + # Test sending an attribute report + await send_attributes_report(hass, thrm_cluster, {"operation_preset": preset_attr}) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == preset_mode + + # Test setting the preset + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + assert thrm_cluster.write_attributes.mock_calls == [ + call( + {"operation_preset": preset_attr}, + manufacturer=device_climate_beca.manufacturer_code, + ) + ] + + async def test_set_zonnsmart_preset( hass: HomeAssistant, device_climate_zonnsmart ) -> None: diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 17665994806..77d8a615c72 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -26,6 +26,7 @@ from homeassistant.components.zha.core.const import ( EZSP_OVERWRITE_EUI64, RadioType, ) +from homeassistant.components.zha.radio_manager import ProbeResult from homeassistant.config_entries import ( SOURCE_SSDP, SOURCE_USB, @@ -80,6 +81,9 @@ def mock_app(): "can_rewrite_custom_eui64": False, } } + mock_app.add_listener = MagicMock() + mock_app.groups = MagicMock() + mock_app.devices = MagicMock() with patch( "zigpy.application.ControllerApplication.new", AsyncMock(return_value=mock_app) @@ -111,7 +115,10 @@ def backup(make_backup): return make_backup() -def mock_detect_radio_type(radio_type=RadioType.ezsp, ret=True): +def mock_detect_radio_type( + radio_type: RadioType = RadioType.ezsp, + ret: ProbeResult = ProbeResult.RADIO_TYPE_DETECTED, +): """Mock `detect_radio_type` that just sets the appropriate attributes.""" async def detect(self): @@ -486,8 +493,11 @@ async def test_zigate_discovery_via_usb(probe_mock, hass: HomeAssistant) -> None } -@patch(f"bellows.{PROBE_FUNCTION_PATH}", return_value=False) -async def test_discovery_via_usb_no_radio(probe_mock, hass: HomeAssistant) -> None: +@patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + AsyncMock(return_value=ProbeResult.PROBING_FAILED), +) +async def test_discovery_via_usb_no_radio(hass: HomeAssistant) -> None: """Test usb flow -- no radio detected.""" discovery_info = usb.UsbServiceInfo( device="/dev/null", @@ -756,7 +766,7 @@ async def test_user_flow(hass: HomeAssistant) -> None: @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", - mock_detect_radio_type(ret=False), + AsyncMock(return_value=ProbeResult.PROBING_FAILED), ) @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) async def test_user_flow_not_detected(hass: HomeAssistant) -> None: @@ -848,6 +858,7 @@ async def test_detect_radio_type_success( handler = config_flow.ZhaConfigFlowHandler() handler._radio_mgr.device_path = "/dev/null" + handler.hass = hass await handler._radio_mgr.detect_radio_type() @@ -876,6 +887,8 @@ async def test_detect_radio_type_success_with_settings( handler = config_flow.ZhaConfigFlowHandler() handler._radio_mgr.device_path = "/dev/null" + handler.hass = hass + await handler._radio_mgr.detect_radio_type() assert handler._radio_mgr.radio_type == RadioType.ezsp @@ -953,22 +966,10 @@ async def test_user_port_config(probe_mock, hass: HomeAssistant) -> None: ], ) async def test_migration_ti_cc_to_znp( - old_type, new_type, hass: HomeAssistant, config_entry + old_type, new_type, hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test zigpy-cc to zigpy-znp config migration.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id=old_type + new_type, - data={ - CONF_RADIO_TYPE: old_type, - CONF_DEVICE: { - CONF_DEVICE_PATH: "/dev/ttyUSB1", - CONF_BAUDRATE: 115200, - CONF_FLOWCONTROL: None, - }, - }, - ) - + config_entry.data = {**config_entry.data, CONF_RADIO_TYPE: old_type} config_entry.version = 2 config_entry.add_to_hass(hass) @@ -1916,3 +1917,44 @@ async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> result["data_schema"].schema["path"].container[0] == "socket://core-silabs-multiprotocol:9999 - Multiprotocol add-on - Nabu Casa" ) + + +@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) +async def test_probe_wrong_firmware_installed(hass: HomeAssistant) -> None: + """Test auto-probing failing because the wrong firmware is installed.""" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + return_value=ProbeResult.WRONG_FIRMWARE_INSTALLED, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: "choose_serial_port"}, + data={ + CONF_DEVICE_PATH: ( + "/dev/ttyUSB1234 - Some serial port, s/n: 1234 - Virtual serial port" + ) + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_firmware_installed" + + +async def test_discovery_wrong_firmware_installed(hass: HomeAssistant) -> None: + """Test auto-probing failing because the wrong firmware is installed.""" + + with patch( + "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", + return_value=ProbeResult.WRONG_FIRMWARE_INSTALLED, + ), patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: "confirm"}, + data={}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_firmware_installed" diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 7c4198bd881..08f84613ff3 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -39,6 +39,8 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_capture_events, mock_restore_cache +Default_Response = zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Default_Response].schema + @pytest.fixture(autouse=True) def cover_platform_only(): @@ -206,6 +208,121 @@ async def test_cover( assert hass.states.get(entity_id).state == STATE_OPEN +async def test_cover_failures( + hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device +) -> None: + """Test ZHA cover platform failure cases.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints.get(1).window_covering + cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 100} + zha_device = await zha_device_joined_restored(zigpy_cover_device) + + 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 that the state has changed from unavailable to off + await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) + 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}) + assert hass.states.get(entity_id).state == STATE_OPEN + + # close from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.down_close.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to close cover"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.down_close.id + ) + + # open from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.up_open.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to open cover"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.up_open.id + ) + + # set position UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to set cover position"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, "position": 47}, + blocking=True, + ) + + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.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, + {"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 ) -> None: @@ -236,7 +353,13 @@ async def test_shade( assert hass.states.get(entity_id).state == STATE_OPEN # close from UI command fails - with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.down_close.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, @@ -244,7 +367,7 @@ async def test_shade( {"entity_id": entity_id}, blocking=True, ) - assert cluster_on_off.request.call_count == 3 + assert cluster_on_off.request.call_count == 1 assert cluster_on_off.request.call_args[0][0] is False assert cluster_on_off.request.call_args[0][1] == 0x0000 assert hass.states.get(entity_id).state == STATE_OPEN @@ -261,7 +384,13 @@ async def test_shade( # open from UI command fails assert ATTR_CURRENT_POSITION not in hass.states.get(entity_id).attributes await send_attributes_report(hass, cluster_level, {0: 0}) - with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.up_open.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, @@ -269,11 +398,35 @@ async def test_shade( {"entity_id": entity_id}, blocking=True, ) - assert cluster_on_off.request.call_count == 3 + assert cluster_on_off.request.call_count == 1 assert cluster_on_off.request.call_args[0][0] is False assert cluster_on_off.request.call_args[0][1] == 0x0001 assert hass.states.get(entity_id).state == STATE_CLOSED + # stop from UI command fails + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=general.LevelControl.ServerCommandDefs.stop.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {"entity_id": entity_id}, + blocking=True, + ) + + assert cluster_level.request.call_count == 1 + assert cluster_level.request.call_args[0][0] is False + assert ( + cluster_level.request.call_args[0][1] + == general.LevelControl.ServerCommandDefs.stop.id + ) + assert hass.states.get(entity_id).state == STATE_CLOSED + # open from UI succeeds with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -285,7 +438,13 @@ async def test_shade( assert hass.states.get(entity_id).state == STATE_OPEN # set position UI command fails - with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, @@ -293,7 +452,8 @@ async def test_shade( {"entity_id": entity_id, "position": 47}, blocking=True, ) - assert cluster_level.request.call_count == 3 + + assert cluster_level.request.call_count == 1 assert cluster_level.request.call_args[0][0] is False assert cluster_level.request.call_args[0][1] == 0x0004 assert int(cluster_level.request.call_args[0][3] * 100 / 255) == 47 diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 46cdff180e9..31ffe9449e2 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -19,11 +19,7 @@ from homeassistant.setup import async_setup_component from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import ( - async_get_device_automations, - async_mock_service, - mock_coro, -) +from tests.common import async_get_device_automations, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -278,7 +274,7 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: with patch( "zigpy.zcl.Cluster.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ): assert await async_setup_component( hass, diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 0bb06ea723b..6bcb321ab14 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -15,6 +15,7 @@ from homeassistant.helpers.device_registry import async_get from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import MockConfigEntry from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -57,7 +58,7 @@ def zigpy_device(zigpy_device_mock): async def test_diagnostics_for_config_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry, + config_entry: MockConfigEntry, zha_device_joined, zigpy_device, ) -> None: @@ -86,12 +87,11 @@ async def test_diagnostics_for_config_entry( async def test_diagnostics_for_device( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry, + config_entry: MockConfigEntry, zha_device_joined, zigpy_device, ) -> None: """Test diagnostics for device.""" - zha_device: ZHADevice = await zha_device_joined(zigpy_device) dev_reg = async_get(hass) device = dev_reg.async_get_device(identifiers={("zha", str(zha_device.ieee))}) diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index f93467ed3e1..3d0b065ab18 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, call, patch import pytest import zhaquirks.ikea.starkvind +from zigpy.device import Device from zigpy.exceptions import ZigbeeException from zigpy.profiles import zha from zigpy.zcl.clusters import general, hvac @@ -17,6 +18,7 @@ from homeassistant.components.fan import ( SERVICE_SET_PRESET_MODE, NotValidPresetModeError, ) +from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.discovery import GROUP_PROBE from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.fan import ( @@ -34,6 +36,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from .common import ( @@ -192,26 +195,30 @@ async def test_fan( # turn on from HA cluster.write_attributes.reset_mock() await async_turn_on(hass, entity_id) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 2}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 2}, manufacturer=None) + ] # turn off from HA cluster.write_attributes.reset_mock() await async_turn_off(hass, entity_id) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 0}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] # change speed from HA cluster.write_attributes.reset_mock() await async_set_percentage(hass, entity_id, percentage=100) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 3}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 3}, manufacturer=None) + ] # change preset_mode from HA cluster.write_attributes.reset_mock() await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_ON) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 4}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 4}, manufacturer=None) + ] # set invalid preset_mode from HA cluster.write_attributes.reset_mock() @@ -443,13 +450,14 @@ async def test_zha_group_fan_entity_failure_state( # turn on from HA group_fan_cluster.write_attributes.reset_mock() - await async_turn_on(hass, entity_id) + + with pytest.raises(HomeAssistantError): + await async_turn_on(hass, entity_id) + await hass.async_block_till_done() assert len(group_fan_cluster.write_attributes.mock_calls) == 1 assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 2} - assert "Could not set fan mode" in caplog.text - @pytest.mark.parametrize( ("plug_read", "expected_state", "expected_percentage"), @@ -557,7 +565,9 @@ def zigpy_device_ikea(zigpy_device_mock): async def test_fan_ikea( - hass: HomeAssistant, zha_device_joined_restored, zigpy_device_ikea + hass: HomeAssistant, + zha_device_joined_restored: ZHADevice, + zigpy_device_ikea: Device, ) -> None: """Test ZHA fan Ikea platform.""" zha_device = await zha_device_joined_restored(zigpy_device_ikea) @@ -587,26 +597,30 @@ async def test_fan_ikea( # turn on from HA cluster.write_attributes.reset_mock() await async_turn_on(hass, entity_id) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 1}, manufacturer=None) + ] # turn off from HA cluster.write_attributes.reset_mock() await async_turn_off(hass, entity_id) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 0}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] # change speed from HA cluster.write_attributes.reset_mock() await async_set_percentage(hass, entity_id, percentage=100) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 10}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 10}, manufacturer=None) + ] # change preset_mode from HA cluster.write_attributes.reset_mock() await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 1}, manufacturer=None) + ] # set invalid preset_mode from HA cluster.write_attributes.reset_mock() diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index c58aaedcbbc..0f791a08955 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,9 +1,9 @@ """Test ZHA Gateway.""" import asyncio -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest +from zigpy.application import ControllerApplication import zigpy.exceptions import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general @@ -232,68 +232,89 @@ async def test_gateway_create_group_with_id( ) @patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) @pytest.mark.parametrize( - "startup", + "startup_effect", [ - [asyncio.TimeoutError(), FileNotFoundError(), MagicMock()], - [asyncio.TimeoutError(), MagicMock()], - [MagicMock()], + [asyncio.TimeoutError(), FileNotFoundError(), None], + [asyncio.TimeoutError(), None], + [None], ], ) async def test_gateway_initialize_success( - startup: list[Any], + startup_effect: list[Exception | None], hass: HomeAssistant, device_light_1: ZHADevice, coordinator: ZHADevice, + zigpy_app_controller: ControllerApplication, ) -> None: """Test ZHA initializing the gateway successfully.""" zha_gateway = get_zha_gateway(hass) assert zha_gateway is not None - zha_gateway.shutdown = AsyncMock() + zigpy_app_controller.startup.side_effect = startup_effect + zigpy_app_controller.startup.reset_mock() with patch( - "bellows.zigbee.application.ControllerApplication.new", side_effect=startup - ) as mock_new: + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): await zha_gateway.async_initialize() - assert mock_new.call_count == len(startup) - + assert zigpy_app_controller.startup.call_count == len(startup_effect) device_light_1.async_cleanup_handles() @patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) async def test_gateway_initialize_failure( - hass: HomeAssistant, device_light_1, coordinator + hass: HomeAssistant, + device_light_1: ZHADevice, + coordinator: ZHADevice, + zigpy_app_controller: ControllerApplication, ) -> None: """Test ZHA failing to initialize the gateway.""" zha_gateway = get_zha_gateway(hass) assert zha_gateway is not None + zigpy_app_controller.startup.side_effect = [ + asyncio.TimeoutError(), + RuntimeError(), + FileNotFoundError(), + ] + zigpy_app_controller.startup.reset_mock() + with patch( "bellows.zigbee.application.ControllerApplication.new", - side_effect=[asyncio.TimeoutError(), FileNotFoundError(), RuntimeError()], - ) as mock_new, pytest.raises(RuntimeError): + return_value=zigpy_app_controller, + ), pytest.raises(FileNotFoundError): await zha_gateway.async_initialize() - assert mock_new.call_count == 3 + assert zigpy_app_controller.startup.call_count == 3 @patch("homeassistant.components.zha.core.gateway.STARTUP_FAILURE_DELAY_S", 0.01) async def test_gateway_initialize_failure_transient( - hass: HomeAssistant, device_light_1, coordinator + hass: HomeAssistant, + device_light_1: ZHADevice, + coordinator: ZHADevice, + zigpy_app_controller: ControllerApplication, ) -> None: """Test ZHA failing to initialize the gateway but with a transient error.""" zha_gateway = get_zha_gateway(hass) assert zha_gateway is not None + zigpy_app_controller.startup.side_effect = [ + RuntimeError(), + zigpy.exceptions.TransientConnectionError(), + ] + zigpy_app_controller.startup.reset_mock() + with patch( "bellows.zigbee.application.ControllerApplication.new", - side_effect=[RuntimeError(), zigpy.exceptions.TransientConnectionError()], - ) as mock_new, pytest.raises(ConfigEntryNotReady): + return_value=zigpy_app_controller, + ), pytest.raises(ConfigEntryNotReady): await zha_gateway.async_initialize() # Initialization immediately stops and is retried after TransientConnectionError - assert mock_new.call_count == 2 + assert zigpy_app_controller.startup.call_count == 2 @patch( @@ -313,7 +334,12 @@ async def test_gateway_initialize_failure_transient( ], ) async def test_gateway_initialize_bellows_thread( - device_path, thread_state, config_override, hass: HomeAssistant, coordinator + device_path: str, + thread_state: bool, + config_override: dict, + hass: HomeAssistant, + coordinator: ZHADevice, + zigpy_app_controller: ControllerApplication, ) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" zha_gateway = get_zha_gateway(hass) @@ -325,11 +351,11 @@ async def test_gateway_initialize_bellows_thread( with patch( "bellows.zigbee.application.ControllerApplication.new", - new=AsyncMock(), + return_value=zigpy_app_controller, ) as mock_new: await zha_gateway.async_initialize() - assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state + assert mock_new.mock_calls[0].kwargs["config"]["use_thread"] is thread_state @pytest.mark.parametrize( diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 24ee63fb3d5..63ca10bbf91 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,8 +1,10 @@ """Tests for ZHA integration init.""" +import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( @@ -11,10 +13,13 @@ from homeassistant.components.zha.core.const import ( CONF_USB_PATH, DOMAIN, ) -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION +from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component +from .test_light import LIGHT_ON_OFF + from tests.common import MockConfigEntry DATA_RADIO_TYPE = "deconz" @@ -157,3 +162,46 @@ async def test_setup_with_v3_cleaning_uri( assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path assert config_entry_v3.version == 3 + + +@patch( + "homeassistant.components.zha.PLATFORMS", + [Platform.LIGHT, Platform.BUTTON, Platform.SENSOR, Platform.SELECT], +) +async def test_zha_retry_unique_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + caplog, +) -> None: + """Test that ZHA retrying creates unique entity IDs.""" + + config_entry.add_to_hass(hass) + + # Ensure we have some device to try to load + app = mock_zigpy_connect.return_value + light = zigpy_device_mock(LIGHT_ON_OFF) + app.devices[light.ieee] = light + + # Re-try setup but have it fail once, so entities have two chances to be created + with patch.object( + app, + "startup", + side_effect=[TransientConnectionError(), None], + ) as mock_connect: + with patch( + "homeassistant.config_entries.async_call_later", + lambda hass, delay, action: async_call_later(hass, 0, action), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Wait for the config entry setup to retry + await asyncio.sleep(0.1) + + assert len(mock_connect.mock_calls) == 2 + + await hass.config_entries.async_unload(config_entry.entry_id) + + assert "does not generate unique IDs" not in caplog.text diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 60aa355af5f..3d888a57a28 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -9,8 +9,10 @@ import zigpy.zcl.clusters.lighting as lighting import zigpy.zcl.foundation as zcl_f from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.zha.core.device import ZHADevice from homeassistant.const import STATE_UNAVAILABLE, EntityCategory, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -23,8 +25,6 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import mock_coro - @pytest.fixture(autouse=True) def number_platform_only(): @@ -153,7 +153,7 @@ async def test_number( # change value from HA with patch( "zigpy.zcl.Cluster.write_attributes", - return_value=mock_coro([zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS]), + return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS], ): # set value via UI await hass.services.async_call( @@ -162,8 +162,9 @@ async def test_number( {"entity_id": entity_id, "value": 30.0}, blocking=True, ) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call({"present_value": 30.0}) + assert cluster.write_attributes.mock_calls == [ + call({"present_value": 30.0}, manufacturer=None) + ] cluster.PLUGGED_ATTR_READS["present_value"] = 30.0 # test rejoin @@ -200,7 +201,12 @@ async def test_number( ), ) async def test_level_control_number( - hass: HomeAssistant, light, zha_device_joined, attr, initial_value, new_value + hass: HomeAssistant, + light: ZHADevice, + zha_device_joined, + attr: str, + initial_value: int, + new_value: int, ) -> None: """Test ZHA level control number entities - new join.""" @@ -219,8 +225,7 @@ async def test_level_control_number( ) assert entity_id is not None - assert level_control_cluster.read_attributes.call_count == 3 - assert ( + assert level_control_cluster.read_attributes.mock_calls == [ call( [ "on_off_transition_time", @@ -232,21 +237,13 @@ async def test_level_control_number( allow_cache=True, only_cache=False, manufacturer=None, - ) - in level_control_cluster.read_attributes.call_args_list - ) - - assert ( + ), call( ["start_up_current_level"], allow_cache=True, only_cache=False, manufacturer=None, - ) - in level_control_cluster.read_attributes.call_args_list - ) - - assert ( + ), call( [ "current_level", @@ -254,9 +251,8 @@ async def test_level_control_number( allow_cache=False, only_cache=False, manufacturer=None, - ) - in level_control_cluster.read_attributes.call_args_list - ) + ), + ] state = hass.states.get(entity_id) assert state @@ -277,10 +273,9 @@ async def test_level_control_number( blocking=True, ) - assert level_control_cluster.write_attributes.call_count == 1 - assert level_control_cluster.write_attributes.call_args[0][0] == { - attr: new_value, - } + assert level_control_cluster.write_attributes.mock_calls == [ + call({attr: new_value}, manufacturer=None) + ] state = hass.states.get(entity_id) assert state @@ -295,36 +290,34 @@ async def test_level_control_number( ) # the mocking doesn't update the attr cache so this flips back to initial value assert hass.states.get(entity_id).state == str(initial_value) - assert level_control_cluster.read_attributes.call_count == 1 - assert ( + assert level_control_cluster.read_attributes.mock_calls == [ call( - [ - attr, - ], + [attr], allow_cache=False, only_cache=False, manufacturer=None, ) - in level_control_cluster.read_attributes.call_args_list - ) + ] level_control_cluster.write_attributes.reset_mock() level_control_cluster.write_attributes.side_effect = ZigbeeException - await hass.services.async_call( - "number", - "set_value", - { - "entity_id": entity_id, - "value": new_value, - }, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": new_value, + }, + blocking=True, + ) - assert level_control_cluster.write_attributes.call_count == 1 - assert level_control_cluster.write_attributes.call_args[0][0] == { - attr: new_value, - } + assert level_control_cluster.write_attributes.mock_calls == [ + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + ] assert hass.states.get(entity_id).state == str(initial_value) @@ -333,7 +326,12 @@ async def test_level_control_number( (("start_up_color_temperature", 500, 350),), ) async def test_color_number( - hass: HomeAssistant, light, zha_device_joined, attr, initial_value, new_value + hass: HomeAssistant, + light: ZHADevice, + zha_device_joined, + attr: str, + initial_value: int, + new_value: int, ) -> None: """Test ZHA color number entities - new join.""" @@ -409,9 +407,7 @@ async def test_color_number( assert color_cluster.read_attributes.call_count == 1 assert ( call( - [ - attr, - ], + [attr], allow_cache=False, only_cache=False, manufacturer=None, @@ -422,18 +418,20 @@ async def test_color_number( color_cluster.write_attributes.reset_mock() color_cluster.write_attributes.side_effect = ZigbeeException - await hass.services.async_call( - "number", - "set_value", - { - "entity_id": entity_id, - "value": new_value, - }, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": entity_id, + "value": new_value, + }, + blocking=True, + ) - assert color_cluster.write_attributes.call_count == 1 - assert color_cluster.write_attributes.call_args[0][0] == { - attr: new_value, - } + assert color_cluster.write_attributes.mock_calls == [ + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + ] assert hass.states.get(entity_id).state == str(initial_value) diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 4d90d83d483..7acf9219d67 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -14,6 +14,7 @@ from homeassistant import config_entries from homeassistant.components.usb import UsbServiceInfo from homeassistant.components.zha import radio_manager from homeassistant.components.zha.core.const import DOMAIN, RadioType +from homeassistant.components.zha.radio_manager import ProbeResult, ZhaRadioManager from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -31,7 +32,9 @@ def disable_platform_only(): @pytest.fixture(autouse=True) def reduce_reconnect_timeout(): """Reduces reconnect timeout to speed up tests.""" - with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001): + with patch( + "homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001 + ), patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001): yield @@ -57,10 +60,13 @@ def backup(): return backup -def mock_detect_radio_type(radio_type=RadioType.ezsp, ret=True): +def mock_detect_radio_type( + radio_type: RadioType = RadioType.ezsp, + ret: ProbeResult = ProbeResult.RADIO_TYPE_DETECTED, +): """Mock `detect_radio_type` that just sets the appropriate attributes.""" - async def detect(self): + async def detect(self) -> ProbeResult: self.radio_type = radio_type self.device_settings = radio_type.controller.SCHEMA_DEVICE( {CONF_DEVICE_PATH: self.device_path} @@ -83,7 +89,7 @@ def com_port(device="/dev/ttyUSB1234"): @pytest.fixture -def mock_connect_zigpy_app() -> Generator[None, None, None]: +def mock_connect_zigpy_app() -> Generator[MagicMock, None, None]: """Mock the radio connection.""" mock_connect_app = MagicMock() @@ -96,7 +102,7 @@ def mock_connect_zigpy_app() -> Generator[None, None, None]: "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", return_value=mock_connect_app, ): - yield + yield mock_connect_app @patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) @@ -370,3 +376,107 @@ async def test_migrate_non_matching_port( "radio_type": "ezsp", } assert config_entry.title == "Test" + + +async def test_migrate_initiate_failure( + hass: HomeAssistant, + mock_connect_zigpy_app, +) -> None: + """Test retries with failure.""" + # Set up the config entry + config_entry = MockConfigEntry( + data={"device": {"path": "/dev/ttyTEST123"}, "radio_type": "ezsp"}, + domain=DOMAIN, + options={}, + title="Test", + ) + config_entry.add_to_hass(hass) + config_entry.state = config_entries.ConfigEntryState.SETUP_IN_PROGRESS + + migration_data = { + "new_discovery_info": { + "name": "Test Updated", + "port": { + "path": "socket://some/virtual_port", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "efr32", + }, + "old_discovery_info": { + "hw": { + "name": "Test", + "port": { + "path": "/dev/ttyTEST123", + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "efr32", + } + }, + } + + mock_load_info = AsyncMock(side_effect=OSError()) + mock_connect_zigpy_app.__aenter__.return_value.load_network_info = mock_load_info + + migration_helper = radio_manager.ZhaMultiPANMigrationHelper(hass, config_entry) + + with pytest.raises(OSError): + await migration_helper.async_initiate_migration(migration_data) + + assert len(mock_load_info.mock_calls) == radio_manager.BACKUP_RETRIES + + +@pytest.fixture(name="radio_manager") +def zha_radio_manager(hass: HomeAssistant) -> ZhaRadioManager: + """Fixture for an instance of `ZhaRadioManager`.""" + radio_manager = ZhaRadioManager() + radio_manager.hass = hass + radio_manager.device_path = "/dev/ttyZigbee" + return radio_manager + + +async def test_detect_radio_type_success(radio_manager: ZhaRadioManager) -> None: + """Test radio type detection, success.""" + with patch( + "bellows.zigbee.application.ControllerApplication.probe", return_value=False + ), patch( + # Intentionally probe only the second radio type + "zigpy_znp.zigbee.application.ControllerApplication.probe", + return_value=True, + ): + assert ( + await radio_manager.detect_radio_type() == ProbeResult.RADIO_TYPE_DETECTED + ) + assert radio_manager.radio_type == RadioType.znp + + +async def test_detect_radio_type_failure_wrong_firmware( + radio_manager: ZhaRadioManager, +) -> None: + """Test radio type detection, wrong firmware.""" + with patch( + "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () + ), patch( + "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + return_value=True, + ): + assert ( + await radio_manager.detect_radio_type() + == ProbeResult.WRONG_FIRMWARE_INSTALLED + ) + assert radio_manager.radio_type is None + + +async def test_detect_radio_type_failure_no_detect( + radio_manager: ZhaRadioManager, +) -> None: + """Test radio type detection, no firmware detected.""" + with patch( + "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () + ), patch( + "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + return_value=False, + ): + assert await radio_manager.detect_radio_type() == ProbeResult.PROBING_FAILED + assert radio_manager.radio_type is None diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py new file mode 100644 index 00000000000..18705168a3f --- /dev/null +++ b/tests/components/zha/test_repairs.py @@ -0,0 +1,235 @@ +"""Test ZHA repairs.""" +from collections.abc import Callable +import logging +from unittest.mock import patch + +import pytest +from universal_silabs_flasher.const import ApplicationType +from universal_silabs_flasher.flasher import Flasher + +from homeassistant.components.homeassistant_sky_connect import ( + DOMAIN as SKYCONNECT_DOMAIN, +) +from homeassistant.components.zha.core.const import DOMAIN +from homeassistant.components.zha.repairs import ( + DISABLE_MULTIPAN_URL, + ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + HardwareType, + _detect_radio_hardware, + probe_silabs_firmware_type, + warn_on_wrong_silabs_firmware, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + +SKYCONNECT_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0" + + +def set_flasher_app_type(app_type: ApplicationType) -> Callable[[Flasher], None]: + """Set the app type on the flasher.""" + + def replacement(self: Flasher) -> None: + self.app_type = app_type + + return replacement + + +def test_detect_radio_hardware(hass: HomeAssistant) -> None: + """Test logic to detect radio hardware.""" + skyconnect_config_entry = MockConfigEntry( + data={ + "device": SKYCONNECT_DEVICE, + "vid": "10C4", + "pid": "EA60", + "serial_number": "3c0ed67c628beb11b1cd64a0f320645d", + "manufacturer": "Nabu Casa", + "description": "SkyConnect v1.0", + }, + domain=SKYCONNECT_DOMAIN, + options={}, + title="Home Assistant SkyConnect", + ) + skyconnect_config_entry.add_to_hass(hass) + + assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.SKYCONNECT + assert ( + _detect_radio_hardware(hass, SKYCONNECT_DEVICE + "_foo") == HardwareType.OTHER + ) + assert _detect_radio_hardware(hass, "/dev/ttyAMA1") == HardwareType.OTHER + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value={"board": "yellow"}, + ): + assert _detect_radio_hardware(hass, "/dev/ttyAMA1") == HardwareType.YELLOW + assert _detect_radio_hardware(hass, "/dev/ttyAMA2") == HardwareType.OTHER + assert ( + _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.SKYCONNECT + ) + + +def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None: + """Test radio hardware detection failure.""" + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.async_info", + side_effect=HomeAssistantError(), + ), patch( + "homeassistant.components.homeassistant_sky_connect.hardware.async_info", + side_effect=HomeAssistantError(), + ): + assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER + + +@pytest.mark.parametrize( + ("detected_hardware", "expected_learn_more_url"), + [ + (HardwareType.SKYCONNECT, DISABLE_MULTIPAN_URL[HardwareType.SKYCONNECT]), + (HardwareType.YELLOW, DISABLE_MULTIPAN_URL[HardwareType.YELLOW]), + (HardwareType.OTHER, None), + ], +) +async def test_multipan_firmware_repair( + hass: HomeAssistant, + detected_hardware: HardwareType, + expected_learn_more_url: str, + config_entry: MockConfigEntry, + mock_zigpy_connect, +) -> None: + """Test creating a repair when multi-PAN firmware is installed and probed.""" + + config_entry.add_to_hass(hass) + + # ZHA fails to set up + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=set_flasher_app_type(ApplicationType.CPC), + autospec=True, + ), patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=RuntimeError(), + ), patch( + "homeassistant.components.zha.repairs._detect_radio_hardware", + return_value=detected_hardware, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + issue_registry = ir.async_get(hass) + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + + # The issue is created when we fail to probe + assert issue is not None + assert issue.translation_placeholders["firmware_type"] == "CPC" + assert issue.learn_more_url == expected_learn_more_url + + # If ZHA manages to start up normally after this, the issue will be deleted + with mock_zigpy_connect: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + assert issue is None + + +async def test_multipan_firmware_no_repair_on_probe_failure( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that a repair is not created when multi-PAN firmware cannot be probed.""" + + config_entry.add_to_hass(hass) + + # ZHA fails to set up + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=set_flasher_app_type(None), + autospec=True, + ), patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=RuntimeError(), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + # No repair is created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + assert issue is None + + +async def test_multipan_firmware_retry_on_probe_ezsp( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect, +) -> None: + """Test that ZHA is reloaded when EZSP firmware is probed.""" + + config_entry.add_to_hass(hass) + + # ZHA fails to set up + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=set_flasher_app_type(ApplicationType.EZSP), + autospec=True, + ), patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=RuntimeError(), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # The config entry state is `SETUP_RETRY`, not `SETUP_ERROR`! + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + await hass.config_entries.async_unload(config_entry.entry_id) + + # No repair is created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, + ) + assert issue is None + + +async def test_no_warn_on_socket(hass: HomeAssistant) -> None: + """Test that no warning is issued when the device is a socket.""" + with patch( + "homeassistant.components.zha.repairs.probe_silabs_firmware_type", autospec=True + ) as mock_probe: + await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678") + + mock_probe.assert_not_called() + + +async def test_probe_failure_exception_handling(caplog) -> None: + """Test that probe failures are handled gracefully.""" + with patch( + "homeassistant.components.zha.repairs.Flasher.probe_app_type", + side_effect=RuntimeError(), + ), caplog.at_level(logging.DEBUG): + await probe_silabs_firmware_type("/dev/ttyZigbee") + + assert "Failed to probe application type" in caplog.text diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 2df6c2be5db..b953d833330 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -27,7 +27,7 @@ import homeassistant.util.dt as dt_util from .common import async_enable_traffic, find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE -from tests.common import async_fire_time_changed, mock_coro +from tests.common import async_fire_time_changed @pytest.fixture(autouse=True) @@ -87,7 +87,7 @@ async def test_siren(hass: HomeAssistant, siren) -> None: # turn on from HA with patch( "zigpy.device.Device.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ), patch( "zigpy.zcl.Cluster.request", side_effect=zigpy.zcl.Cluster.request, @@ -119,7 +119,7 @@ async def test_siren(hass: HomeAssistant, siren) -> None: # turn off from HA with patch( "zigpy.device.Device.request", - return_value=mock_coro([0x01, zcl_f.Status.SUCCESS]), + return_value=[0x01, zcl_f.Status.SUCCESS], ), patch( "zigpy.zcl.Cluster.request", side_effect=zigpy.zcl.Cluster.request, @@ -151,7 +151,7 @@ async def test_siren(hass: HomeAssistant, siren) -> None: # turn on from HA with patch( "zigpy.device.Device.request", - return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + return_value=[0x00, zcl_f.Status.SUCCESS], ), patch( "zigpy.zcl.Cluster.request", side_effect=zigpy.zcl.Cluster.request, diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index bee7ec409ca..fe7450eff67 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -21,6 +21,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.zha.core.group import GroupMember from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from .common import ( @@ -411,10 +412,11 @@ async def test_switch_configurable( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call( - {"window_detection_function": True} - ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": True}, manufacturer=None) + ] + + cluster.write_attributes.reset_mock() # turn off from HA with patch( @@ -425,10 +427,9 @@ async def test_switch_configurable( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(cluster.write_attributes.mock_calls) == 2 - assert cluster.write_attributes.call_args == call( - {"window_detection_function": False} - ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": False}, manufacturer=None) + ] cluster.read_attributes.reset_mock() await async_setup_component(hass, "homeassistant", {}) @@ -461,14 +462,18 @@ async def test_switch_configurable( cluster.write_attributes.reset_mock() cluster.write_attributes.side_effect = ZigbeeException - await hass.services.async_call( - SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + ) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call( - {"window_detection_function": False} - ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": False}, manufacturer=None), + call({"window_detection_function": False}, manufacturer=None), + call({"window_detection_function": False}, manufacturer=None), + ] + + cluster.write_attributes.side_effect = None # test inverter cluster.write_attributes.reset_mock() @@ -477,18 +482,17 @@ async def test_switch_configurable( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(cluster.write_attributes.mock_calls) == 1 - assert cluster.write_attributes.call_args == call( - {"window_detection_function": True} - ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": True}, manufacturer=None) + ] + cluster.write_attributes.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(cluster.write_attributes.mock_calls) == 2 - assert cluster.write_attributes.call_args == call( - {"window_detection_function": False} - ) + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": False}, manufacturer=None) + ] # test joining a new switch to the network and HA await async_test_rejoin(hass, zigpy_device_tuya, [cluster], (0,)) diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 0904fc1f685..740ffd6c06c 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -13,6 +13,7 @@ import zigpy.profiles.zha import zigpy.types from zigpy.types.named import EUI64 import zigpy.zcl.clusters.general as general +from zigpy.zcl.clusters.general import Groups import zigpy.zcl.clusters.security as security import zigpy.zdo.types as zdo_types @@ -233,7 +234,7 @@ async def test_list_devices(zha_client) -> None: msg = await zha_client.receive_json() devices = msg["result"] - assert len(devices) == 2 + assert len(devices) == 2 + 1 # the coordinator is included as well msg_id = 100 for device in devices: @@ -371,8 +372,13 @@ async def test_get_group_not_found(zha_client) -> None: assert msg["error"]["code"] == const.ERR_NOT_FOUND -async def test_list_groupable_devices(zha_client, device_groupable) -> None: +async def test_list_groupable_devices( + zha_client, device_groupable, zigpy_app_controller +) -> None: """Test getting ZHA devices that have a group cluster.""" + # Ensure the coordinator doesn't have a group cluster + coordinator = zigpy_app_controller.get_device(nwk=0x0000) + del coordinator.endpoints[1].in_clusters[Groups.cluster_id] await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) @@ -479,6 +485,7 @@ async def app_controller( ) -> ControllerApplication: """Fixture for zigpy Application Controller.""" await setup_zha() + zigpy_app_controller.permit.reset_mock() return zigpy_app_controller diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 0eb4ec775f9..dcd847a6e12 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -3,7 +3,7 @@ import asyncio import copy import io import json -from unittest.mock import AsyncMock, patch +from unittest.mock import DEFAULT, AsyncMock, patch import pytest from zwave_js_server.event import Event @@ -688,6 +688,17 @@ def mock_client_fixture( client.version = VersionInfo.from_message(version_state) client.ws_server_url = "ws://test:3000/zjs" + async def async_send_command_side_effect(message, require_schema=None): + """Return the command response.""" + if message["command"] == "node.has_device_config_changed": + return {"changed": False} + return DEFAULT + + client.async_send_command.return_value = { + "result": {"success": True, "status": 255} + } + client.async_send_command.side_effect = async_send_command_side_effect + yield client diff --git a/tests/components/zwave_js/fixtures/controller_state.json b/tests/components/zwave_js/fixtures/controller_state.json index 566ad3b6f2b..d6d9dcacd9e 100644 --- a/tests/components/zwave_js/fixtures/controller_state.json +++ b/tests/components/zwave_js/fixtures/controller_state.json @@ -24,7 +24,8 @@ "sucNodeId": 1, "supportsTimers": false, "isHealNetworkActive": false, - "inclusionState": 0 + "inclusionState": 0, + "status": 0 }, "nodes": [] } diff --git a/tests/components/zwave_js/fixtures/nortek_thermostat_removed_event.json b/tests/components/zwave_js/fixtures/nortek_thermostat_removed_event.json index 8491e65c037..e30e0297e7d 100644 --- a/tests/components/zwave_js/fixtures/nortek_thermostat_removed_event.json +++ b/tests/components/zwave_js/fixtures/nortek_thermostat_removed_event.json @@ -270,5 +270,5 @@ } ] }, - "replaced": false + "reason": 0 } diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index ebdf2112435..02ed507cabe 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -276,14 +276,16 @@ async def test_subscribe_node_status( msg = await ws_client.receive_json() assert msg["success"] - node.data["ready"] = True + new_node_data = deepcopy(multisensor_6_state) + new_node_data["ready"] = True + event = Event( "ready", { "source": "node", "event": "ready", "nodeId": node.node_id, - "nodeState": node.data, + "nodeState": new_node_data, }, ) node.receive_event(event) @@ -1715,7 +1717,7 @@ async def test_remove_node( assert len(client.async_send_command.call_args_list) == 1 assert client.async_send_command.call_args[0][0] == { "command": "controller.begin_exclusion", - "strategy": 0, + "options": {"strategy": 0}, } # Test FailedZWaveCommand is caught @@ -3296,22 +3298,21 @@ async def test_subscribe_log_updates( } # Test FailedZWaveCommand is caught - with patch( - "zwave_js_server.model.driver.Driver.async_start_listening_logs", - side_effect=FailedZWaveCommand("failed_command", 1, "error message"), - ): - await ws_client.send_json( - { - ID: 2, - TYPE: "zwave_js/subscribe_log_updates", - ENTRY_ID: entry.entry_id, - } - ) - msg = await ws_client.receive_json() + client.async_start_listening_logs.side_effect = FailedZWaveCommand( + "failed_command", 1, "error message" + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/subscribe_log_updates", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "zwave_error" - assert msg["error"]["message"] == "Z-Wave error 1: error message" + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) @@ -3678,7 +3679,6 @@ async def test_abort_firmware_update( ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) - client.async_send_command.return_value = {} await ws_client.send_json( { ID: 1, @@ -3689,8 +3689,8 @@ async def test_abort_firmware_update( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.abort_firmware_update" assert args["nodeId"] == multisensor_6.node_id diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 23d34c131b8..e9040dfd397 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -731,6 +731,8 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_dry_preset 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 @@ -765,6 +767,7 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset 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 diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 502f2413c99..e51b3751ac8 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -126,6 +126,7 @@ async def test_window_cover( assert args["value"] client.async_send_command.reset_mock() + # Test stop after opening await hass.services.async_call( DOMAIN, @@ -265,6 +266,7 @@ async def test_fibaro_fgr222_shutter_cover( assert args["value"] == 99 client.async_send_command.reset_mock() + # Test closing tilts await hass.services.async_call( DOMAIN, @@ -286,6 +288,7 @@ async def test_fibaro_fgr222_shutter_cover( assert args["value"] == 0 client.async_send_command.reset_mock() + # Test setting tilt position await hass.services.async_call( DOMAIN, @@ -365,6 +368,7 @@ async def test_aeotec_nano_shutter_cover( assert args["value"] client.async_send_command.reset_mock() + # Test stop after opening await hass.services.async_call( DOMAIN, diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 8551427cf3e..fec9ec4cbbb 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -25,10 +25,7 @@ from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg from homeassistant.setup import async_setup_component -from tests.common import ( - async_get_device_automations, - async_mock_service, -) +from tests.common import async_get_device_automations, async_mock_service @pytest.fixture diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index 4454e38e0d8..2510143695c 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -125,7 +125,13 @@ async def test_device_diagnostics( entity["entity_id"] == "test.unrelated_entity" for entity in diagnostics_data["entities"] ) - assert diagnostics_data["state"] == multisensor_6.data + assert diagnostics_data["state"] == { + **multisensor_6.data, + "values": {id: val.data for id, val in multisensor_6.values.items()}, + "endpoints": { + str(idx): endpoint.data for idx, endpoint in multisensor_6.endpoints.items() + }, + } async def test_device_diagnostics_error(hass: HomeAssistant, integration) -> None: @@ -230,7 +236,11 @@ async def test_device_diagnostics_secret_value( """Find ultraviolet property value in data.""" return next( val - for val in data["values"] + for val in ( + data["values"] + if isinstance(data["values"], list) + else data["values"].values() + ) if val["commandClass"] == CommandClass.SENSOR_MULTILEVEL and val["property"] == PROPERTY_ULTRAVIOLET ) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 1c4a69d32e3..cbaa27c2a91 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -171,6 +171,7 @@ async def test_zooz_zen72( state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN + await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -226,7 +227,9 @@ async def test_indicator_test( 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)) == 2 # include node status + assert ( + len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + ) # include node + controller status assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" @@ -256,6 +259,7 @@ async def test_indicator_test( state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF + await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2b508700413..92141eec3ff 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -231,6 +231,7 @@ async def test_configurable_speeds_fan( async def get_zwave_speed_from_percentage(percentage): """Set the fan to a particular percentage and get the resulting Zwave speed.""" client.async_send_command.reset_mock() + await hass.services.async_call( "fan", "turn_on", @@ -356,6 +357,7 @@ async def test_ge_12730_fan(hass: HomeAssistant, client, ge_12730, integration) async def get_zwave_speed_from_percentage(percentage): """Set the fan to a particular percentage and get the resulting Zwave speed.""" client.async_send_command.reset_mock() + await hass.services.async_call( "fan", "turn_on", @@ -448,6 +450,7 @@ async def test_inovelli_lzw36( async def get_zwave_speed_from_percentage(percentage): """Set the fan to a particular percentage and get the resulting Zwave speed.""" client.async_send_command.reset_mock() + await hass.services.async_call( "fan", "turn_on", @@ -518,6 +521,7 @@ async def test_inovelli_lzw36( assert state.attributes[ATTR_PERCENTAGE] is None client.async_send_command.reset_mock() + await hass.services.async_call( "fan", "turn_on", @@ -553,6 +557,7 @@ async def test_leviton_zw4sf_fan( async def get_zwave_speed_from_percentage(percentage): """Set the fan to a particular percentage and get the resulting Zwave speed.""" client.async_send_command.reset_mock() + await hass.services.async_call( "fan", "turn_on", @@ -951,6 +956,7 @@ async def test_honeywell_39358_fan( async def get_zwave_speed_from_percentage(percentage): """Set the fan to a particular percentage and get the resulting Zwave speed.""" client.async_send_command.reset_mock() + await hass.services.async_call( "fan", "turn_on", diff --git a/tests/components/zwave_js/test_helpers.py b/tests/components/zwave_js/test_helpers.py index aaa2907d30a..b40c09b249d 100644 --- a/tests/components/zwave_js/test_helpers.py +++ b/tests/components/zwave_js/test_helpers.py @@ -1,17 +1,24 @@ """Test the Z-Wave JS helpers module.""" +import voluptuous as vol + from homeassistant.components.zwave_js.helpers import ( async_get_node_status_sensor_entity_id, async_get_nodes_from_area_id, + get_value_state_schema, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, device_registry as dr +from tests.common import MockConfigEntry + async def test_async_get_node_status_sensor_entity_id(hass: HomeAssistant) -> None: """Test async_get_node_status_sensor_entity_id for non zwave_js device.""" dev_reg = dr.async_get(hass) + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) device = dev_reg.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry.entry_id, identifiers={("test", "test")}, ) assert async_get_node_status_sensor_entity_id(hass, device.id) is None @@ -22,3 +29,14 @@ async def test_async_get_nodes_from_area_id(hass: HomeAssistant) -> None: area_reg = ar.async_get(hass) area = area_reg.async_create("test") assert not async_get_nodes_from_area_id(hass, area.id) + + +async def test_get_value_state_schema_boolean_config_value( + hass: HomeAssistant, client, aeon_smart_switch_6 +) -> None: + """Test get_value_state_schema for boolean config value.""" + schema_validator = get_value_state_schema( + aeon_smart_switch_6.values["102-112-0-255"] + ) + assert isinstance(schema_validator, vol.Coerce) + assert schema_validator.type == bool diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 3ec1f113b3e..6985a7bf252 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -11,6 +11,7 @@ from zwave_js_server.model.node import Node from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState @@ -25,7 +26,7 @@ from homeassistant.helpers import ( from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_get_persistent_notifications @pytest.fixture(name="connect_timeout") @@ -204,7 +205,7 @@ async def test_on_node_added_not_ready( dev_reg = dr.async_get(hass) device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" - assert len(hass.states.async_all()) == 0 + assert len(hass.states.async_all()) == 1 assert len(dev_reg.devices) == 1 node_state = deepcopy(zp3111_not_ready_state) @@ -223,7 +224,7 @@ async def test_on_node_added_not_ready( await hass.async_block_till_done() # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -325,7 +326,7 @@ async def test_existing_node_not_ready( assert not device.sw_version # the only entities are the node status sensor and ping button - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 3 device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert device @@ -963,7 +964,7 @@ async def test_removed_device( # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 91 + assert len(entity_entries) == 92 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -975,7 +976,7 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 2 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 60 + assert len(entity_entries) == 61 assert ( dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None ) @@ -1005,7 +1006,7 @@ async def test_node_removed( event = { "source": "controller", "event": "node added", - "node": node.data, + "node": multisensor_6_state, "result": {}, } @@ -1014,7 +1015,7 @@ async def test_node_removed( old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) assert old_device.id - event = {"node": node, "replaced": False} + event = {"node": node, "reason": 0} client.driver.controller.emit("node removed", event) await hass.async_block_till_done() @@ -1047,14 +1048,14 @@ async def test_replace_same_node( assert hass.states.get(AIR_TEMPERATURE_SENSOR) - # A replace node event has the extra field "replaced" set to True + # A replace node event has the extra field "reason" # to distinguish it from an exclusion event = Event( type="node removed", data={ "source": "controller", "event": "node removed", - "replaced": True, + "reason": 3, "node": multisensor_6_state, }, ) @@ -1139,8 +1140,8 @@ async def test_replace_different_node( """Test when a node is replaced with a different node.""" dev_reg = dr.async_get(hass) node_id = multisensor_6.node_id - hank_binary_switch_state = deepcopy(hank_binary_switch_state) - hank_binary_switch_state["nodeId"] = node_id + state = deepcopy(hank_binary_switch_state) + state["nodeId"] = node_id device_id = f"{client.driver.controller.home_id}-{node_id}" multisensor_6_device_id = ( @@ -1148,9 +1149,9 @@ async def test_replace_different_node( f"{multisensor_6.product_type}:{multisensor_6.product_id}" ) hank_device_id = ( - f"{device_id}-{hank_binary_switch_state['manufacturerId']}:" - f"{hank_binary_switch_state['productType']}:" - f"{hank_binary_switch_state['productId']}" + f"{device_id}-{state['manufacturerId']}:" + f"{state['productType']}:" + f"{state['productId']}" ) device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)}) @@ -1171,7 +1172,7 @@ async def test_replace_different_node( data={ "source": "controller", "event": "node removed", - "replaced": True, + "reason": 3, "node": multisensor_6_state, }, ) @@ -1228,7 +1229,7 @@ async def test_replace_different_node( "source": "node", "event": "ready", "nodeId": node_id, - "nodeState": hank_binary_switch_state, + "nodeState": state, }, ) client.driver.receive_event(event) @@ -1345,7 +1346,7 @@ async def test_disabled_node_status_entity_on_node_replaced( data={ "source": "controller", "event": "node removed", - "replaced": True, + "reason": 3, "node": zp3111_state, }, ) @@ -1501,3 +1502,51 @@ async def test_disabled_entity_on_value_removed( } == new_unavailable_entities ) + + +async def test_identify_event( + hass: HomeAssistant, client, multisensor_6, integration +) -> None: + """Test controller identify event.""" + # One config entry scenario + event = Event( + type="identify", + data={ + "source": "controller", + "event": "identify", + "nodeId": multisensor_6.node_id, + }, + ) + dev_id = get_device_id(client.driver, multisensor_6) + msg_id = f"{DOMAIN}.identify_controller.{dev_id[1]}" + + client.driver.controller.receive_event(event) + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert notifications[msg_id]["message"].startswith("`Multisensor 6`") + assert "with the home ID" not in notifications[msg_id]["message"] + async_dismiss(hass, msg_id) + + # Add mock config entry to simulate having multiple entries + new_entry = MockConfigEntry(domain=DOMAIN) + new_entry.add_to_hass(hass) + + # Test case where config entry title and home ID don't match + client.driver.controller.receive_event(event) + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert ( + "network `Mock Title`, with the home ID `3245146787`" + in notifications[msg_id]["message"] + ) + async_dismiss(hass, msg_id) + + # Test case where config entry title and home ID do match + hass.config_entries.async_update_entry(integration, title="3245146787") + client.driver.controller.receive_event(event) + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 7229d10ebad..7a3ffbda589 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -124,7 +124,7 @@ async def test_number_writeable( blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert len(client.async_send_command.call_args_list) == 2 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == 4 diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py new file mode 100644 index 00000000000..07371a299ef --- /dev/null +++ b/tests/components/zwave_js/test_repairs.py @@ -0,0 +1,159 @@ +"""Test the Z-Wave JS repairs module.""" +from copy import deepcopy +from http import HTTPStatus +from unittest.mock import patch + +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.components.repairs.issue_handler import ( + async_process_repairs_platforms, +) +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) +from homeassistant.components.zwave_js import DOMAIN +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.issue_registry as ir + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +async def test_device_config_file_changed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test the device_config_file_changed issue.""" + dev_reg = dr.async_get(hass) + # Create a node + node_state = deepcopy(multisensor_6_state) + node = Node(client, node_state) + event = Event( + "node added", + { + "source": "controller", + "event": "node added", + "node": node_state, + "result": "", + }, + ) + with patch( + "zwave_js_server.model.node.Node.async_has_device_config_changed", + return_value=True, + ): + client.driver.controller.receive_event(event) + await hass.async_block_till_done() + + 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"] == "confirm" + assert data["description_placeholders"] == {"device_name": device.name} + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + assert client.async_send_command_no_wait.call_args[0][0] == { + "command": "node.refresh_info", + "nodeId": node.node_id, + } + + # Assert the issue is resolved + 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"]) == 0 + + +async def test_invalid_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + integration, +) -> None: + """Test the invalid issue.""" + ir.async_create_issue( + hass, + DOMAIN, + "invalid_issue_id", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="invalid_issue", + ) + + 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"] == "invalid_issue_id" + + url = RepairsFlowIndexView.url + resp = await http_client.post( + url, json={"handler": DOMAIN, "issue_id": "invalid_issue_id"} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + await hass.async_block_till_done() + + # Assert the issue is resolved + 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"]) == 0 diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index d809d52821c..d452f28b3bf 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -261,6 +261,47 @@ async def test_config_parameter_sensor( await hass.async_block_till_done() +async def test_controller_status_sensor( + hass: HomeAssistant, client, integration +) -> None: + """Test controller status sensor is created and gets updated on controller state changes.""" + entity_id = "sensor.z_stick_gen5_usb_controller_status" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + assert not entity_entry.disabled + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + state = hass.states.get(entity_id) + assert state + assert state.state == "ready" + assert state.attributes[ATTR_ICON] == "mdi:check" + + event = Event( + "status changed", + data={"source": "controller", "event": "status changed", "status": 1}, + ) + client.driver.controller.receive_event(event) + 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( + "status changed", + data={"source": "controller", "event": "status changed", "status": 2}, + ) + client.driver.controller.receive_event(event) + 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() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + async def test_node_status_sensor( hass: HomeAssistant, client, lock_id_lock_as_id150, integration ) -> None: @@ -325,6 +366,16 @@ async def test_node_status_sensor( is None ) + # Assert a controller status sensor entity is not created for a node + assert ( + ent_reg.async_get_entity_id( + DOMAIN, + "sensor", + f"{get_valueless_base_unique_id(driver, node)}.controller_status", + ) + is None + ) + async def test_node_status_sensor_not_ready( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 54638358fe7..ccbe956fbe5 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -414,6 +414,7 @@ async def test_bulk_set_config_parameters( identifiers={get_device_id(client.driver, multisensor_6)} ) assert device + # Test setting config parameter by property and property_key await hass.services.async_call( DOMAIN, @@ -875,7 +876,9 @@ async def test_set_value( client.async_send_command.reset_mock() # Test that when a command fails we raise an exception - client.async_send_command.return_value = {"success": False} + client.async_send_command.return_value = { + "result": {"status": 2, "message": "test"} + } with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -924,7 +927,6 @@ async def test_set_value_string( hass: HomeAssistant, client, climate_danfoss_lc_13, lock_schlage_be469, integration ) -> None: """Test set_value service converts number to string when needed.""" - client.async_send_command.return_value = {"success": True} # Test that number gets converted to a string when needed await hass.services.async_call( @@ -1240,7 +1242,9 @@ async def test_multicast_set_value( ) # Test that when a command is unsuccessful we raise an exception - client.async_send_command.return_value = {"success": False} + client.async_send_command.return_value = { + "result": {"status": 2, "message": "test"} + } with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -1381,7 +1385,7 @@ async def test_multicast_set_value_string( integration, ) -> None: """Test multicast_set_value service converts number to string when needed.""" - client.async_send_command.return_value = {"success": True} + client.async_send_command.return_value = {"result": {"status": 255}} # Test that number gets converted to a string when needed await hass.services.async_call( diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index ebf7d9f441f..fd5c626bdd2 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -63,6 +63,8 @@ async def test_switch( state = hass.states.get(SWITCH_ENTITY) assert state.state == "on" + client.async_send_command.reset_mock() + # Test turning off await hass.services.async_call( "switch", "turn_off", {"entity_id": SWITCH_ENTITY}, blocking=True diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 501ad13cbaa..25553489b4e 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -1158,7 +1158,7 @@ async def test_server_reconnect_event( data={ "source": "controller", "event": "node removed", - "replaced": False, + "reason": 0, "node": lock_schlage_be469_state, }, ) @@ -1238,7 +1238,7 @@ async def test_server_reconnect_value_updated( data={ "source": "controller", "event": "node removed", - "replaced": False, + "reason": 0, "node": lock_schlage_be469_state, }, ) diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index dcd71789e84..9314b9155f5 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -264,17 +264,19 @@ async def test_update_entity_ha_not_running( """Test update occurs only after HA is running.""" await hass.async_stop() + client.async_send_command.return_value = {"updates": []} + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + 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 @@ -282,15 +284,15 @@ async def test_update_entity_ha_not_running( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 1 hass.state = CoreState.running async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[1][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == zen_31.node_id @@ -341,7 +343,9 @@ async def test_update_entity_progress( assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": False} + client.async_send_command.return_value = { + "result": {"status": 2, "success": False, "reInterview": False} + } # Test successful install call without a version install_task = hass.async_create_task( @@ -437,7 +441,9 @@ async def test_update_entity_install_failed( assert attrs[ATTR_LATEST_VERSION] == "11.2.4" client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": False} + client.async_send_command.return_value = { + "result": {"status": 2, "success": False, "reInterview": False} + } # Test install call - we expect it to finish fail install_task = hass.async_create_task( @@ -577,6 +583,7 @@ async def test_update_entity_delay( ) -> None: """Test update occurs on a delay after HA starts.""" client.async_send_command.reset_mock() + client.async_send_command.return_value = {"updates": []} await hass.async_stop() entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) @@ -584,26 +591,26 @@ async def test_update_entity_delay( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 2 await hass.async_start() await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 0 + assert len(client.async_send_command.call_args_list) == 2 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 - args = client.async_send_command.call_args_list[0][0][0] + assert len(client.async_send_command.call_args_list) == 3 + args = client.async_send_command.call_args_list[2][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[1][0][0] + assert len(client.async_send_command.call_args_list) == 4 + args = client.async_send_command.call_args_list[3][0][0] assert args["command"] == "controller.get_available_firmware_updates" assert args["nodeId"] == zen_31.node_id @@ -710,7 +717,9 @@ async def test_update_entity_full_restore_data_update_available( assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" - client.async_send_command.return_value = {"success": True} + client.async_send_command.return_value = { + "result": {"status": 255, "success": True, "reInterview": False} + } # Test successful install call without a version install_task = hass.async_create_task( @@ -732,8 +741,8 @@ async def test_update_entity_full_restore_data_update_available( attrs = state.attributes assert attrs[ATTR_IN_PROGRESS] is True - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args_list[0][0][0] == { + assert len(client.async_send_command.call_args_list) == 2 + assert client.async_send_command.call_args_list[1][0][0] == { "command": "controller.firmware_update_ota", "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], diff --git a/tests/conftest.py b/tests/conftest.py index 40fd1c2eef0..f90984e1c7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator, Callable, Coroutine, Generator from contextlib import asynccontextmanager -import datetime import functools import gc import itertools @@ -32,6 +31,9 @@ import pytest_socket import requests_mock from syrupy.assertion import SnapshotAssertion +# Setup patching if dt_util time functions before any other Home Assistant imports +from . import patch_time # noqa: F401, isort:skip + from homeassistant import core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials @@ -53,7 +55,6 @@ from homeassistant.helpers import ( config_entry_oauth2_flow, device_registry as dr, entity_registry as er, - event, issue_registry as ir, recorder as recorder_helper, ) @@ -109,15 +110,6 @@ asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(False)) asyncio.set_event_loop_policy = lambda policy: None -def _utcnow() -> datetime.datetime: - """Make utcnow patchable by freezegun.""" - return datetime.datetime.now(datetime.UTC) - - -dt_util.utcnow = _utcnow # type: ignore[assignment] -event.time_tracker_utcnow = _utcnow # type: ignore[assignment] - - def pytest_addoption(parser: pytest.Parser) -> None: """Register custom pytest options.""" parser.addoption("--dburl", action="store", default="sqlite://") @@ -560,8 +552,8 @@ async def stop_hass( created = [] - def mock_hass(): - hass_inst = orig_hass() + def mock_hass(*args): + hass_inst = orig_hass(*args) created.append(hass_inst) return hass_inst @@ -744,10 +736,10 @@ def hass_client( ) -> ClientSessionGenerator: """Return an authenticated HTTP client.""" - async def auth_client() -> TestClient: + async def auth_client(access_token: str | None = hass_access_token) -> TestClient: """Return an authenticated client.""" return await aiohttp_client( - hass.http.app, headers={"Authorization": f"Bearer {hass_access_token}"} + hass.http.app, headers={"Authorization": f"Bearer {access_token}"} ) return auth_client diff --git a/tests/fixtures/smhi.json b/tests/fixtures/smhi.json deleted file mode 100644 index e2da28534a0..00000000000 --- a/tests/fixtures/smhi.json +++ /dev/null @@ -1,1252 +0,0 @@ -{ - "approvedTime": "2018-09-01T14:06:18Z", - "referenceTime": "2018-09-01T14:00:00Z", - "geometry": { - "type": "Point", - "coordinates": [[16.024394, 63.341937]] - }, - "timeSeries": [ - { - "validTime": "2018-09-01T15:00:00Z", - "parameters": [ - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [2] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1024.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [17] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [134] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.9] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [55] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [33] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.7] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2018-09-02T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1026] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [12] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [214] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [0.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [87] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2018-09-02T11:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1026.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [19.8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [201] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.8] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [43] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.2] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - } - ] - }, - { - "validTime": "2018-09-02T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1026.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [20.6] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [203] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.7] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [43] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [9] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [6] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [5.1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - } - ] - }, - { - "validTime": "2018-09-02T23:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1026] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [9.3] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [19.4] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [95] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [0.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [75] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2018-09-03T00:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1025.9] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [8.5] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [104] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [0.5] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [73] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2018-09-03T01:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1025.6] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [8] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [116] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [0.3] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [74] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [1] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [1] - } - ] - }, - { - "validTime": "2018-09-04T12:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1020.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [19.2] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [353] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [1.4] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [60] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [7] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [3] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [5] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.7] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [-9] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [0] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - } - ] - }, - { - "validTime": "2018-09-04T18:00:00Z", - "parameters": [ - { - "name": "msl", - "levelType": "hmsl", - "level": 0, - "unit": "hPa", - "values": [1021.5] - }, - { - "name": "t", - "levelType": "hl", - "level": 2, - "unit": "Cel", - "values": [14.3] - }, - { - "name": "vis", - "levelType": "hl", - "level": 2, - "unit": "km", - "values": [50] - }, - { - "name": "wd", - "levelType": "hl", - "level": 10, - "unit": "degree", - "values": [333] - }, - { - "name": "ws", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [2.3] - }, - { - "name": "r", - "levelType": "hl", - "level": 2, - "unit": "percent", - "values": [81] - }, - { - "name": "tstm", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "tcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "lcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [1] - }, - { - "name": "mcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [4] - }, - { - "name": "hcc_mean", - "levelType": "hl", - "level": 0, - "unit": "octas", - "values": [0] - }, - { - "name": "gust", - "levelType": "hl", - "level": 10, - "unit": "m/s", - "values": [4.5] - }, - { - "name": "pmin", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmax", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0.2] - }, - { - "name": "spp", - "levelType": "hl", - "level": 0, - "unit": "percent", - "values": [0] - }, - { - "name": "pcat", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [4] - }, - { - "name": "pmean", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "pmedian", - "levelType": "hl", - "level": 0, - "unit": "kg/m2/h", - "values": [0] - }, - { - "name": "Wsymb2", - "levelType": "hl", - "level": 0, - "unit": "category", - "values": [3] - } - ] - } - ] -} diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index fdbe9eb316b..2512f426f13 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -303,7 +303,7 @@ async def test_and_condition_shorthand(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) assert config["alias"] == "And Condition Shorthand" - assert "and" not in config.keys() + assert "and" not in config hass.states.async_set("sensor.temperature", 120) assert not test(hass) @@ -345,7 +345,7 @@ async def test_and_condition_list_shorthand(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) assert config["alias"] == "And Condition List Shorthand" - assert "and" not in config.keys() + assert "and" not in config hass.states.async_set("sensor.temperature", 120) assert not test(hass) @@ -577,7 +577,7 @@ async def test_or_condition_shorthand(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) assert config["alias"] == "Or Condition Shorthand" - assert "or" not in config.keys() + assert "or" not in config hass.states.async_set("sensor.temperature", 120) assert not test(hass) @@ -809,7 +809,7 @@ async def test_not_condition_shorthand(hass: HomeAssistant) -> None: test = await condition.async_from_config(hass, config) assert config["alias"] == "Not Condition Shorthand" - assert "not" not in config.keys() + assert "not" not in config hass.states.async_set("sensor.temperature", 101) assert test(hass) diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 3baaf7e7333..94cdf34cba3 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -140,7 +140,7 @@ async def test_abort_if_authorization_timeout( flow.hass = hass with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout", + "homeassistant.helpers.config_entry_oauth2_flow.asyncio.timeout", side_effect=asyncio.TimeoutError, ): result = await flow.async_step_user() @@ -331,7 +331,7 @@ async def test_abort_on_oauth_timeout_error( assert resp.headers["content-type"] == "text/html; charset=utf-8" with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_timeout.timeout", + "homeassistant.helpers.config_entry_oauth2_flow.asyncio.timeout", side_effect=asyncio.TimeoutError, ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index b5c8cc1716e..80fc1bf2241 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1221,7 +1221,7 @@ def test_enum() -> None: schema("value3") -def test_socket_timeout(): # pylint: disable=invalid-name +def test_socket_timeout(): """Test socket timeout validator.""" schema = vol.Schema(cv.socket_timeout) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 9ebee025bd5..380574c04fa 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -34,15 +34,24 @@ def update_events(hass): return events +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock config entry and add it to hass.""" + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + return entry + + async def test_get_or_create_returns_same_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, update_events, ) -> None: """Make sure we do not duplicate entries.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, sw_version="sw-version", @@ -52,7 +61,7 @@ async def test_get_or_create_returns_same_entry( suggested_area="Game Room", ) entry2 = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "11:22:33:66:77:88")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -60,7 +69,7 @@ async def test_get_or_create_returns_same_entry( suggested_area="Game Room", ) entry3 = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -99,17 +108,18 @@ async def test_get_or_create_returns_same_entry( async def test_requirement_for_identifier_or_connection( device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, ) -> None: """Make sure we do require some descriptor of device.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers=set(), manufacturer="manufacturer", model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections=set(), identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -122,7 +132,7 @@ async def test_requirement_for_identifier_or_connection( with pytest.raises(HomeAssistantError): device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections=set(), identifiers=set(), manufacturer="manufacturer", @@ -130,24 +140,31 @@ async def test_requirement_for_identifier_or_connection( ) -async def test_multiple_config_entries(device_registry: dr.DeviceRegistry) -> None: +async def test_multiple_config_entries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Make sure we do not get duplicate entries.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry3 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -157,12 +174,14 @@ async def test_multiple_config_entries(device_registry: dr.DeviceRegistry) -> No assert len(device_registry.devices) == 1 assert entry.id == entry2.id assert entry.id == entry3.id - assert entry2.config_entries == {"123", "456"} + assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} @pytest.mark.parametrize("load_registries", [False]) async def test_loading_from_storage( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, ) -> None: """Test loading stored devices on start.""" hass_storage[dr.STORAGE_KEY] = { @@ -172,7 +191,7 @@ async def test_loading_from_storage( "devices": [ { "area_id": "12345A", - "config_entries": ["1234"], + "config_entries": [mock_config_entry.entry_id], "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "disabled_by": dr.DeviceEntryDisabler.USER, @@ -190,7 +209,7 @@ async def test_loading_from_storage( ], "deleted_devices": [ { - "config_entries": ["1234"], + "config_entries": [mock_config_entry.entry_id], "connections": [["Zigbee", "23.45.67.89.01"]], "id": "bcdefghijklmn", "identifiers": [["serial", "34:56:AB:CD:EF:12"]], @@ -206,7 +225,7 @@ async def test_loading_from_storage( assert len(registry.deleted_devices) == 1 entry = registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, identifiers={("serial", "12:34:56:AB:CD:EF")}, manufacturer="manufacturer", @@ -214,7 +233,7 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( area_id="12345A", - config_entries={"1234"}, + config_entries={mock_config_entry.entry_id}, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, disabled_by=dr.DeviceEntryDisabler.USER, @@ -235,14 +254,14 @@ async def test_loading_from_storage( # Restore a device, id should be reused from the deleted device entry entry = registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "23.45.67.89.01")}, identifiers={("serial", "34:56:AB:CD:EF:12")}, manufacturer="manufacturer", model="model", ) assert entry == dr.DeviceEntry( - config_entries={"1234"}, + config_entries={mock_config_entry.entry_id}, connections={("Zigbee", "23.45.67.89.01")}, id="bcdefghijklmn", identifiers={("serial", "34:56:AB:CD:EF:12")}, @@ -257,7 +276,9 @@ async def test_loading_from_storage( @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_1_to_1_3( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, ) -> None: """Test migration from version 1.1 to 1.3.""" hass_storage[dr.STORAGE_KEY] = { @@ -266,7 +287,7 @@ async def test_migration_1_1_to_1_3( "data": { "devices": [ { - "config_entries": ["1234"], + "config_entries": [mock_config_entry.entry_id], "connections": [["Zigbee", "01.23.45.67.89"]], "entry_type": "service", "id": "abcdefghijklm", @@ -310,7 +331,7 @@ async def test_migration_1_1_to_1_3( # Test data was loaded entry = registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, identifiers={("serial", "12:34:56:AB:CD:EF")}, ) @@ -318,7 +339,7 @@ async def test_migration_1_1_to_1_3( # Update to trigger a store entry = registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, identifiers={("serial", "12:34:56:AB:CD:EF")}, sw_version="new_version", @@ -335,7 +356,7 @@ async def test_migration_1_1_to_1_3( "devices": [ { "area_id": None, - "config_entries": ["1234"], + "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "disabled_by": None, @@ -383,7 +404,9 @@ async def test_migration_1_1_to_1_3( @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_2_to_1_3( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, ) -> None: """Test migration from version 1.2 to 1.3.""" hass_storage[dr.STORAGE_KEY] = { @@ -394,7 +417,7 @@ async def test_migration_1_2_to_1_3( "devices": [ { "area_id": None, - "config_entries": ["1234"], + "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "disabled_by": None, @@ -434,7 +457,7 @@ async def test_migration_1_2_to_1_3( # Test data was loaded entry = registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, identifiers={("serial", "12:34:56:AB:CD:EF")}, ) @@ -442,7 +465,7 @@ async def test_migration_1_2_to_1_3( # Update to trigger a store entry = registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, identifiers={("serial", "12:34:56:AB:CD:EF")}, sw_version="new_version", @@ -460,7 +483,7 @@ async def test_migration_1_2_to_1_3( "devices": [ { "area_id": None, - "config_entries": ["1234"], + "config_entries": [mock_config_entry.entry_id], "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "disabled_by": None, @@ -502,22 +525,27 @@ async def test_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events ) -> None: """Make sure we do not get duplicate entries.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry3 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, manufacturer="manufacturer", @@ -527,15 +555,15 @@ async def test_removing_config_entries( assert len(device_registry.devices) == 2 assert entry.id == entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {"123", "456"} + assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} - device_registry.async_clear_config_entry("123") + device_registry.async_clear_config_entry(config_entry_1.entry_id) entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) entry3_removed = device_registry.async_get_device( identifiers={("bridgeid", "4567")} ) - assert entry.config_entries == {"456"} + assert entry.config_entries == {config_entry_2.entry_id} assert entry3_removed is None await hass.async_block_till_done() @@ -546,13 +574,15 @@ async def test_removing_config_entries( assert "changes" not in update_events[0] assert update_events[1]["action"] == "update" assert update_events[1]["device_id"] == entry2.id - assert update_events[1]["changes"] == {"config_entries": {"123"}} + assert update_events[1]["changes"] == {"config_entries": {config_entry_1.entry_id}} assert update_events[2]["action"] == "create" assert update_events[2]["device_id"] == entry3.id assert "changes" not in update_events[2] assert update_events[3]["action"] == "update" assert update_events[3]["device_id"] == entry.id - assert update_events[3]["changes"] == {"config_entries": {"456", "123"}} + assert update_events[3]["changes"] == { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id} + } assert update_events[4]["action"] == "remove" assert update_events[4]["device_id"] == entry3.id assert "changes" not in update_events[4] @@ -562,22 +592,27 @@ async def test_deleted_device_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events ) -> None: """Make sure we do not get duplicate entries.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry3 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, manufacturer="manufacturer", @@ -588,7 +623,7 @@ async def test_deleted_device_removing_config_entries( assert len(device_registry.deleted_devices) == 0 assert entry.id == entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {"123", "456"} + assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry3.id) @@ -603,7 +638,7 @@ async def test_deleted_device_removing_config_entries( assert "changes" not in update_events[0] assert update_events[1]["action"] == "update" assert update_events[1]["device_id"] == entry2.id - assert update_events[1]["changes"] == {"config_entries": {"123"}} + assert update_events[1]["changes"] == {"config_entries": {config_entry_1.entry_id}} assert update_events[2]["action"] == "create" assert update_events[2]["device_id"] == entry3.id assert "changes" not in update_events[2]["device_id"] @@ -614,11 +649,11 @@ async def test_deleted_device_removing_config_entries( assert update_events[4]["device_id"] == entry3.id assert "changes" not in update_events[4] - device_registry.async_clear_config_entry("123") + device_registry.async_clear_config_entry(config_entry_1.entry_id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 - device_registry.async_clear_config_entry("456") + device_registry.async_clear_config_entry(config_entry_2.entry_id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 @@ -628,7 +663,7 @@ async def test_deleted_device_removing_config_entries( # Re-add, expect to keep the device id entry2 = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -644,7 +679,7 @@ async def test_deleted_device_removing_config_entries( # Re-add, expect to get a new device id after the purge entry4 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -653,10 +688,12 @@ async def test_deleted_device_removing_config_entries( assert entry3.id != entry4.id -async def test_removing_area_id(device_registry: dr.DeviceRegistry) -> None: +async def test_removing_area_id( + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +) -> None: """Make sure we can clear area id.""" entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -672,10 +709,17 @@ async def test_removing_area_id(device_registry: dr.DeviceRegistry) -> None: assert entry_w_area != entry_wo_area -async def test_specifying_via_device_create(device_registry: dr.DeviceRegistry) -> None: +async def test_specifying_via_device_create( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test specifying a via_device and removal of the hub device.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + via = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("hue", "0123")}, manufacturer="manufacturer", @@ -683,7 +727,7 @@ async def test_specifying_via_device_create(device_registry: dr.DeviceRegistry) ) light = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections=set(), identifiers={("hue", "456")}, manufacturer="manufacturer", @@ -698,10 +742,17 @@ async def test_specifying_via_device_create(device_registry: dr.DeviceRegistry) assert light.via_device_id is None -async def test_specifying_via_device_update(device_registry: dr.DeviceRegistry) -> None: +async def test_specifying_via_device_update( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test specifying a via_device and updating.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + light = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections=set(), identifiers={("hue", "456")}, manufacturer="manufacturer", @@ -712,7 +763,7 @@ async def test_specifying_via_device_update(device_registry: dr.DeviceRegistry) assert light.via_device_id is None via = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("hue", "0123")}, manufacturer="manufacturer", @@ -720,7 +771,7 @@ async def test_specifying_via_device_update(device_registry: dr.DeviceRegistry) ) light = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections=set(), identifiers={("hue", "456")}, manufacturer="manufacturer", @@ -735,8 +786,19 @@ async def test_loading_saving_data( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: """Test that we load/save data correctly.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry() + config_entry_3.add_to_hass(hass) + config_entry_4 = MockConfigEntry() + config_entry_4.add_to_hass(hass) + config_entry_5 = MockConfigEntry() + config_entry_5.add_to_hass(hass) + orig_via = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("hue", "0123")}, manufacturer="manufacturer", @@ -747,7 +809,7 @@ async def test_loading_saving_data( ) orig_light = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections=set(), identifiers={("hue", "456")}, manufacturer="manufacturer", @@ -757,7 +819,7 @@ async def test_loading_saving_data( ) orig_light2 = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections=set(), identifiers={("hue", "789")}, manufacturer="manufacturer", @@ -768,7 +830,7 @@ async def test_loading_saving_data( device_registry.async_remove_device(orig_light2.id) orig_light3 = device_registry.async_get_or_create( - config_entry_id="789", + config_entry_id=config_entry_3.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, identifiers={("hue", "abc")}, manufacturer="manufacturer", @@ -776,7 +838,7 @@ async def test_loading_saving_data( ) device_registry.async_get_or_create( - config_entry_id="abc", + config_entry_id=config_entry_4.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, identifiers={("abc", "123")}, manufacturer="manufacturer", @@ -786,7 +848,7 @@ async def test_loading_saving_data( device_registry.async_remove_device(orig_light3.id) orig_light4 = device_registry.async_get_or_create( - config_entry_id="789", + config_entry_id=config_entry_3.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:AB:CD:EF:12")}, identifiers={("hue", "abc")}, manufacturer="manufacturer", @@ -797,7 +859,7 @@ async def test_loading_saving_data( assert orig_light4.id == orig_light3.id orig_kitchen_light = device_registry.async_get_or_create( - config_entry_id="999", + config_entry_id=config_entry_5.entry_id, connections=set(), identifiers={("hue", "999")}, manufacturer="manufacturer", @@ -851,10 +913,12 @@ async def test_loading_saving_data( assert orig_kitchen_light_witout_suggested_area == new_kitchen_light -async def test_no_unnecessary_changes(device_registry: dr.DeviceRegistry) -> None: +async def test_no_unnecessary_changes( + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +) -> None: """Make sure we do not consider devices changes.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={("ethernet", "12:34:56:78:90:AB:CD:EF")}, identifiers={("hue", "456"), ("bla", "123")}, ) @@ -862,22 +926,24 @@ async def test_no_unnecessary_changes(device_registry: dr.DeviceRegistry) -> Non "homeassistant.helpers.device_registry.DeviceRegistry.async_schedule_save" ) as mock_save: entry2 = device_registry.async_get_or_create( - config_entry_id="1234", identifiers={("hue", "456")} + config_entry_id=mock_config_entry.entry_id, identifiers={("hue", "456")} ) assert entry.id == entry2.id assert len(mock_save.mock_calls) == 0 -async def test_format_mac(device_registry: dr.DeviceRegistry) -> None: +async def test_format_mac( + device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry +) -> None: """Make sure we normalize mac addresses.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) for mac in ["123456ABCDEF", "123456abcdef", "12:34:56:ab:cd:ef", "1234.56ab.cdef"]: test_entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, mac)}, ) assert test_entry.id == entry.id, mac @@ -895,18 +961,21 @@ async def test_format_mac(device_registry: dr.DeviceRegistry) -> None: "123.456.abc.def", # too many . ]: invalid_mac_entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, invalid)}, ) assert list(invalid_mac_entry.connections)[0][1] == invalid async def test_update( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + update_events, ) -> None: """Verify that we can update some attributes of a device.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("hue", "456"), ("bla", "123")}, ) @@ -936,7 +1005,7 @@ async def test_update( assert updated_entry != entry assert updated_entry == dr.DeviceEntry( area_id="12345A", - config_entries={"1234"}, + config_entries={mock_config_entry.entry_id}, configuration_url="https://example.com/config", connections={("mac", "12:34:56:ab:cd:ef")}, disabled_by=dr.DeviceEntryDisabler.USER, @@ -1001,22 +1070,27 @@ async def test_update_remove_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events ) -> None: """Make sure we do not get duplicate entries.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry2 = device_registry.async_get_or_create( - config_entry_id="456", + config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", model="model", ) entry3 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, manufacturer="manufacturer", @@ -1026,16 +1100,16 @@ async def test_update_remove_config_entries( assert len(device_registry.devices) == 2 assert entry.id == entry2.id assert entry.id != entry3.id - assert entry2.config_entries == {"123", "456"} + assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} updated_entry = device_registry.async_update_device( - entry2.id, remove_config_entry_id="123" + entry2.id, remove_config_entry_id=config_entry_1.entry_id ) removed_entry = device_registry.async_update_device( - entry3.id, remove_config_entry_id="123" + entry3.id, remove_config_entry_id=config_entry_1.entry_id ) - assert updated_entry.config_entries == {"456"} + assert updated_entry.config_entries == {config_entry_2.entry_id} assert removed_entry is None removed_entry = device_registry.async_get_device(identifiers={("bridgeid", "4567")}) @@ -1050,13 +1124,15 @@ async def test_update_remove_config_entries( assert "changes" not in update_events[0] assert update_events[1]["action"] == "update" assert update_events[1]["device_id"] == entry2.id - assert update_events[1]["changes"] == {"config_entries": {"123"}} + assert update_events[1]["changes"] == {"config_entries": {config_entry_1.entry_id}} assert update_events[2]["action"] == "create" assert update_events[2]["device_id"] == entry3.id assert "changes" not in update_events[2] assert update_events[3]["action"] == "update" assert update_events[3]["device_id"] == entry.id - assert update_events[3]["changes"] == {"config_entries": {"456", "123"}} + assert update_events[3]["changes"] == { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id} + } assert update_events[4]["action"] == "remove" assert update_events[4]["device_id"] == entry3.id assert "changes" not in update_events[4] @@ -1066,11 +1142,12 @@ async def test_update_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, update_events, ) -> None: """Verify that we can update the suggested area version of a device.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bla", "123")}, ) @@ -1122,6 +1199,8 @@ async def test_cleanup_device_registry( """Test cleanup works.""" config_entry = MockConfigEntry(domain="hue") config_entry.add_to_hass(hass) + ghost_config_entry = MockConfigEntry() + ghost_config_entry.add_to_hass(hass) d1 = device_registry.async_get_or_create( identifiers={("hue", "d1")}, config_entry_id=config_entry.entry_id @@ -1133,14 +1212,17 @@ async def test_cleanup_device_registry( identifiers={("hue", "d3")}, config_entry_id=config_entry.entry_id ) device_registry.async_get_or_create( - identifiers={("something", "d4")}, config_entry_id="non_existing" + identifiers={("something", "d4")}, config_entry_id=ghost_config_entry.entry_id ) + # Remove the config entry without triggering the normal cleanup + hass.config_entries._entries.pop(ghost_config_entry.entry_id) ent_reg = er.async_get(hass) ent_reg.async_get_or_create("light", "hue", "e1", device_id=d1.id) ent_reg.async_get_or_create("light", "hue", "e2", device_id=d1.id) ent_reg.async_get_or_create("light", "hue", "e3", device_id=d3.id) + # Manual cleanup should detect the orphaned config entry dr.async_cleanup(hass, device_registry, ent_reg) assert device_registry.async_get_device(identifiers={("hue", "d1")}) is not None @@ -1233,11 +1315,14 @@ async def test_cleanup_entity_registry_change(hass: HomeAssistant) -> None: async def test_restore_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + update_events, ) -> None: """Make sure device id is stable.""" entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -1253,14 +1338,14 @@ async def test_restore_device( assert len(device_registry.deleted_devices) == 1 entry2 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, manufacturer="manufacturer", model="model", ) entry3 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, manufacturer="manufacturer", @@ -1294,11 +1379,14 @@ async def test_restore_device( async def test_restore_simple_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + update_events, ) -> None: """Make sure device id is stable.""" entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, ) @@ -1312,12 +1400,12 @@ async def test_restore_simple_device( assert len(device_registry.deleted_devices) == 1 entry2 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "34:56:78:CD:EF:12")}, identifiers={("bridgeid", "4567")}, ) entry3 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, ) @@ -1348,8 +1436,13 @@ async def test_restore_shared_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, update_events ) -> None: """Make sure device id is stable for shared devices.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + entry = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("entry_123", "0123")}, manufacturer="manufacturer", @@ -1360,7 +1453,7 @@ async def test_restore_shared_device( assert len(device_registry.deleted_devices) == 0 device_registry.async_get_or_create( - config_entry_id="234", + config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("entry_234", "2345")}, manufacturer="manufacturer", @@ -1376,7 +1469,7 @@ async def test_restore_shared_device( assert len(device_registry.deleted_devices) == 1 entry2 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("entry_123", "0123")}, manufacturer="manufacturer", @@ -1394,7 +1487,7 @@ async def test_restore_shared_device( device_registry.async_remove_device(entry.id) entry3 = device_registry.async_get_or_create( - config_entry_id="234", + config_entry_id=config_entry_2.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("entry_234", "2345")}, manufacturer="manufacturer", @@ -1410,7 +1503,7 @@ async def test_restore_shared_device( assert isinstance(entry3.identifiers, set) entry4 = device_registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry_1.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("entry_123", "0123")}, manufacturer="manufacturer", @@ -1434,7 +1527,7 @@ async def test_restore_shared_device( assert update_events[1]["action"] == "update" assert update_events[1]["device_id"] == entry.id assert update_events[1]["changes"] == { - "config_entries": {"123"}, + "config_entries": {config_entry_1.entry_id}, "identifiers": {("entry_123", "0123")}, } assert update_events[2]["action"] == "remove" @@ -1452,17 +1545,18 @@ async def test_restore_shared_device( assert update_events[6]["action"] == "update" assert update_events[6]["device_id"] == entry.id assert update_events[6]["changes"] == { - "config_entries": {"234"}, + "config_entries": {config_entry_2.entry_id}, "identifiers": {("entry_234", "2345")}, } async def test_get_or_create_empty_then_set_default_values( device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, ) -> None: """Test creating an entry, then setting default name, model, manufacturer.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert entry.name is None @@ -1470,7 +1564,7 @@ async def test_get_or_create_empty_then_set_default_values( assert entry.manufacturer is None entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 1", default_model="default model 1", @@ -1481,7 +1575,7 @@ async def test_get_or_create_empty_then_set_default_values( assert entry.manufacturer == "default manufacturer 1" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 2", default_model="default model 2", @@ -1494,10 +1588,11 @@ async def test_get_or_create_empty_then_set_default_values( async def test_get_or_create_empty_then_update( device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, ) -> None: """Test creating an entry, then setting name, model, manufacturer.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert entry.name is None @@ -1505,7 +1600,7 @@ async def test_get_or_create_empty_then_update( assert entry.manufacturer is None entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, name="name 1", model="model 1", @@ -1516,7 +1611,7 @@ async def test_get_or_create_empty_then_update( assert entry.manufacturer == "manufacturer 1" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 1", default_model="default model 1", @@ -1529,10 +1624,11 @@ async def test_get_or_create_empty_then_update( async def test_get_or_create_sets_default_values( device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, ) -> None: """Test creating an entry, then setting default name, model, manufacturer.""" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 1", default_model="default model 1", @@ -1543,7 +1639,7 @@ async def test_get_or_create_sets_default_values( assert entry.manufacturer == "default manufacturer 1" entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 2", default_model="default model 2", @@ -1555,13 +1651,15 @@ async def test_get_or_create_sets_default_values( async def test_verify_suggested_area_does_not_overwrite_area_id( - device_registry: dr.DeviceRegistry, area_registry: ar.AreaRegistry + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + mock_config_entry: MockConfigEntry, ) -> None: """Make sure suggested area does not override a set area id.""" game_room_area = area_registry.async_create("Game Room") original_entry = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, sw_version="sw-version", @@ -1576,7 +1674,7 @@ async def test_verify_suggested_area_does_not_overwrite_area_id( assert entry.area_id == game_room_area.id entry2 = device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=mock_config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, sw_version="sw-version", @@ -1713,16 +1811,21 @@ async def test_device_info_configuration_url_validation( expectation, ) -> None: """Test configuration URL of device info is properly validated.""" + config_entry_1 = MockConfigEntry() + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry() + config_entry_2.add_to_hass(hass) + with expectation: device_registry.async_get_or_create( - config_entry_id="1234", + config_entry_id=config_entry_1.entry_id, identifiers={("something", "1234")}, name="name", configuration_url=configuration_url, ) update_device = device_registry.async_get_or_create( - config_entry_id="5678", + config_entry_id=config_entry_2.entry_id, identifiers={("something", "5678")}, name="name", ) @@ -1734,7 +1837,9 @@ async def test_device_info_configuration_url_validation( @pytest.mark.parametrize("load_registries", [False]) async def test_loading_invalid_configuration_url_from_storage( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, ) -> None: """Test loading stored devices with an invalid URL.""" hass_storage[dr.STORAGE_KEY] = { @@ -1768,6 +1873,7 @@ async def test_loading_invalid_configuration_url_from_storage( registry = dr.async_get(hass) assert len(registry.devices) == 1 entry = registry.async_get_or_create( - config_entry_id="1234", identifiers={("serial", "12:34:56:AB:CD:EF")} + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "12:34:56:AB:CD:EF")}, ) assert entry.configuration_url == "invalid" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 0d9ee76ac62..20bea6a98eb 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta +import logging import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch @@ -1360,6 +1361,7 @@ async def test_friendly_name_updated( 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 ) @@ -1476,3 +1478,30 @@ async def test_warn_no_platform( caplog.clear() ent.async_write_ha_state() assert error_message not in caplog.text + + +async def test_invalid_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the entity helper catches InvalidState and sets state to unknown.""" + ent = entity.Entity() + ent.entity_id = "test.test" + ent.hass = hass + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 + + caplog.clear() + ent._attr_state = "x" * 256 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == STATE_UNKNOWN + assert ( + "homeassistant.helpers.entity", + logging.ERROR, + f"Failed to set state, fall back to {STATE_UNKNOWN}", + ) in caplog.record_tuples + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 3eaad662d8b..0bbfedb8926 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1062,8 +1062,10 @@ async def test_entity_registry_updates_invalid_entity_id(hass: HomeAssistant) -> async def test_device_info_called(hass: HomeAssistant) -> None: """Test device info is forwarded correctly.""" registry = dr.async_get(hass) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) via = registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry.entry_id, connections=set(), identifiers={("hue", "via-id")}, manufacturer="manufacturer", @@ -1098,7 +1100,6 @@ async def test_device_info_called(hass: HomeAssistant) -> None: 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 ) @@ -1126,8 +1127,10 @@ async def test_device_info_called(hass: HomeAssistant) -> None: async def test_device_info_not_overrides(hass: HomeAssistant) -> None: """Test device info is forwarded correctly.""" registry = dr.async_get(hass) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) device = registry.async_get_or_create( - config_entry_id="bla", + config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "abcd")}, manufacturer="test-manufacturer", model="test-model", @@ -1154,7 +1157,6 @@ async def test_device_info_not_overrides(hass: HomeAssistant) -> None: 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 ) @@ -1176,8 +1178,10 @@ async def test_device_info_homeassistant_url( ) -> None: """Test device info with homeassistant URL.""" registry = dr.async_get(hass) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry.entry_id, connections=set(), identifiers={("mqtt", "via-id")}, manufacturer="manufacturer", @@ -1201,7 +1205,6 @@ async def test_device_info_homeassistant_url( 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 ) @@ -1222,8 +1225,10 @@ async def test_device_info_change_to_no_url( ) -> None: """Test device info changes to no URL.""" registry = dr.async_get(hass) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) registry.async_get_or_create( - config_entry_id="123", + config_entry_id=config_entry.entry_id, connections=set(), identifiers={("mqtt", "via-id")}, manufacturer="manufacturer", @@ -1248,7 +1253,6 @@ async def test_device_info_change_to_no_url( 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 ) @@ -1304,6 +1308,7 @@ async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id", domain=DOMAIN) + config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1621,6 +1626,7 @@ async def test_entity_name_influences_entity_id( 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 ) @@ -1690,6 +1696,7 @@ async def test_translated_entity_name_influences_entity_id( 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 ) @@ -1773,6 +1780,7 @@ async def test_translated_device_class_name_influences_entity_id( 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 ) @@ -1845,23 +1853,27 @@ async def test_device_name_defaulting_config_entry( @pytest.mark.parametrize( - ("device_info"), + ("device_info", "number_of_entities"), [ # No identifiers - {}, - {"name": "bla"}, - {"default_name": "bla"}, + ({}, 1), # Empty device info does not prevent the entity from being created + ({"name": "bla"}, 0), + ({"default_name": "bla"}, 0), # Match multiple types - { - "identifiers": {("hue", "1234")}, - "name": "bla", - "default_name": "yo", - }, + ( + { + "identifiers": {("hue", "1234")}, + "name": "bla", + "default_name": "yo", + }, + 0, + ), ], ) async def test_device_type_error_checking( hass: HomeAssistant, device_info: dict, + number_of_entities: int, ) -> None: """Test catching invalid device info.""" @@ -1878,6 +1890,7 @@ async def test_device_type_error_checking( config_entry = MockConfigEntry( title="Mock Config Entry Title", entry_id="super-mock-id" ) + config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1886,6 +1899,6 @@ async def test_device_type_error_checking( dev_reg = dr.async_get(hass) assert len(dev_reg.devices) == 0 - # Entity should still be registered ent_reg = er.async_get(hass) - assert ent_reg.async_get("test_domain.test_qwer") is not None + assert len(ent_reg.entities) == number_of_entities + assert len(hass.states.async_all()) == number_of_entities diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 57622d330d9..f62addb9a64 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1017,6 +1017,7 @@ async def test_remove_device_removes_entities( ) -> None: """Test that we remove entities tied to a device.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -1046,7 +1047,9 @@ async def test_remove_config_entry_from_device_removes_entities( ) -> None: """Test that we remove entities tied to a device when config entry is removed.""" config_entry_1 = MockConfigEntry(domain="hue") + config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") + config_entry_2.add_to_hass(hass) # Create device with two config entries device_registry.async_get_or_create( @@ -1112,7 +1115,9 @@ async def test_remove_config_entry_from_device_removes_entities_2( ) -> None: """Test that we don't remove entities with no config entry when device is modified.""" config_entry_1 = MockConfigEntry(domain="hue") + config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") + config_entry_2.add_to_hass(hass) # Create device with two config entries device_registry.async_get_or_create( @@ -1155,6 +1160,7 @@ async def test_update_device_race( ) -> None: """Test race when a device is created, updated and removed.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) # Create device device_entry = device_registry.async_get_or_create( @@ -1331,6 +1337,7 @@ async def test_disabled_entities_excluded_from_entity_list( ) -> None: """Test that disabled entities are excluded from async_entries_for_device.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 9436226b335..dc06b9d94c8 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -8,7 +8,6 @@ from unittest.mock import patch from astral import LocationInfo import astral.sun -import async_timeout from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import jinja2 @@ -1903,7 +1902,7 @@ async def test_track_template_result_with_wildcard(hass: HomeAssistant) -> None: template_complex_str = r""" {% for state in states %} - {% if state.entity_id | regex_match('.*\.office_') %} + {% if state.entity_id | regex_match('.*\\.office_') %} {{ state.entity_id }}={{ state.state }} {% endif %} {% endfor %} @@ -4175,27 +4174,27 @@ async def test_periodic_task_entering_dst_2( ) freezer.move_to(f"{today} 01:59:59.999999+01:00") - async_fire_time_changed(hass) + async_fire_time_changed_exact(hass) await hass.async_block_till_done() assert len(specific_runs) == 0 freezer.move_to(f"{today} 03:00:00.999999+02:00") - async_fire_time_changed(hass) + async_fire_time_changed_exact(hass) await hass.async_block_till_done() assert len(specific_runs) == 1 freezer.move_to(f"{today} 03:00:01.999999+02:00") - async_fire_time_changed(hass) + async_fire_time_changed_exact(hass) await hass.async_block_till_done() assert len(specific_runs) == 2 freezer.move_to(f"{tomorrow} 01:59:59.999999+02:00") - async_fire_time_changed(hass) + async_fire_time_changed_exact(hass) await hass.async_block_till_done() assert len(specific_runs) == 3 freezer.move_to(f"{tomorrow} 02:00:00.999999+02:00") - async_fire_time_changed(hass) + async_fire_time_changed_exact(hass) await hass.async_block_till_done() assert len(specific_runs) == 4 @@ -4361,7 +4360,7 @@ async def test_call_later(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback was called but the delay was wrong" @@ -4381,7 +4380,7 @@ async def test_async_call_later(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback was called but the delay was wrong" assert isinstance(remove, Callable) remove() @@ -4403,7 +4402,7 @@ async def test_async_call_later_timedelta(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback was called but the delay was wrong" assert isinstance(remove, Callable) remove() @@ -4430,7 +4429,7 @@ async def test_async_call_later_cancel(hass: HomeAssistant) -> None: async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=delay)) with contextlib.suppress(asyncio.TimeoutError): - async with async_timeout.timeout(delay + delay_tolerance): + async with asyncio.timeout(delay + delay_tolerance): assert await future, "callback not canceled" diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 98e93785f58..8d473338058 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -18,6 +18,8 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + class MockIntentHandler(intent.IntentHandler): """Provide a mock intent handler.""" @@ -116,11 +118,15 @@ async def test_match_device_area( entity_registry: er.EntityRegistry, ) -> None: """Test async_match_state with a device in an area.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) area_kitchen = area_registry.async_get_or_create("kitchen") area_bedroom = area_registry.async_get_or_create("bedroom") kitchen_device = device_registry.async_get_or_create( - config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} + config_entry_id=config_entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, ) device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) diff --git a/tests/helpers/test_issue_registry.py b/tests/helpers/test_issue_registry.py index 51cffbc7810..88f97a65421 100644 --- a/tests/helpers/test_issue_registry.py +++ b/tests/helpers/test_issue_registry.py @@ -9,7 +9,7 @@ from homeassistant.helpers import issue_registry as ir from tests.common import async_capture_events, flush_store -async def test_load_issues(hass: HomeAssistant) -> None: +async def test_load_save_issues(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" issues = [ { @@ -109,11 +109,51 @@ async def test_load_issues(hass: HomeAssistant) -> None: "issue_id": "issue_1", } - ir.async_delete_issue(hass, issues[2]["domain"], issues[2]["issue_id"]) + # Update an issue by creating it again with the same value, + # no update event should be fired, as nothing changed. + ir.async_create_issue( + hass, + issues[2]["domain"], + issues[2]["issue_id"], + breaks_in_ha_version=issues[2]["breaks_in_ha_version"], + is_fixable=issues[2]["is_fixable"], + is_persistent=issues[2]["is_persistent"], + learn_more_url=issues[2]["learn_more_url"], + severity=issues[2]["severity"], + translation_key=issues[2]["translation_key"], + translation_placeholders=issues[2]["translation_placeholders"], + ) + await hass.async_block_till_done() + + assert len(events) == 5 + + # Update an issue by creating it again, url changed + ir.async_create_issue( + hass, + issues[2]["domain"], + issues[2]["issue_id"], + breaks_in_ha_version=issues[2]["breaks_in_ha_version"], + is_fixable=issues[2]["is_fixable"], + is_persistent=issues[2]["is_persistent"], + learn_more_url="https://www.example.com/something_changed", + severity=issues[2]["severity"], + translation_key=issues[2]["translation_key"], + translation_placeholders=issues[2]["translation_placeholders"], + ) await hass.async_block_till_done() assert len(events) == 6 assert events[5].data == { + "action": "update", + "domain": "test", + "issue_id": "issue_3", + } + + ir.async_delete_issue(hass, issues[2]["domain"], issues[2]["issue_id"]) + await hass.async_block_till_done() + + assert len(events) == 7 + assert events[6].data == { "action": "remove", "domain": "test", "issue_id": "issue_3", @@ -169,6 +209,77 @@ async def test_load_issues(hass: HomeAssistant) -> None: assert issue4_registry2 == issue4 +@pytest.mark.parametrize("load_registries", [False]) +async def test_load_save_issues_read_only( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Make sure that we don't save data when opened in read-only mode.""" + hass_storage[ir.STORAGE_KEY] = { + "version": ir.STORAGE_VERSION_MAJOR, + "minor_version": ir.STORAGE_VERSION_MINOR, + "data": { + "issues": [ + { + "created": "2022-07-19T09:41:13.746514+00:00", + "dismissed_version": "2022.7.0.dev0", + "domain": "test", + "is_persistent": False, + "issue_id": "issue_1", + }, + ] + }, + } + + issues = [ + { + "breaks_in_ha_version": "2022.8", + "domain": "test", + "issue_id": "issue_2", + "is_fixable": True, + "is_persistent": False, + "learn_more_url": "https://theuselessweb.com/abc", + "severity": "other", + "translation_key": "even_worse", + "translation_placeholders": {"def": "456"}, + }, + ] + + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await ir.async_load(hass, read_only=True) + + for issue in issues: + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + breaks_in_ha_version=issue["breaks_in_ha_version"], + is_fixable=issue["is_fixable"], + is_persistent=issue["is_persistent"], + learn_more_url=issue["learn_more_url"], + severity=issue["severity"], + translation_key=issue["translation_key"], + translation_placeholders=issue["translation_placeholders"], + ) + + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data == { + "action": "create", + "domain": "test", + "issue_id": "issue_2", + } + + registry = ir.async_get(hass) + assert len(registry.issues) == 2 + + registry2 = ir.IssueRegistry(hass) + await flush_store(registry._store) + await registry2.async_load() + + assert len(registry2.issues) == 1 + + @pytest.mark.parametrize("load_registries", [False]) async def test_loading_issues_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 419122b018b..7e248c8c381 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -215,6 +215,20 @@ def test_custom_encoder(tmp_path: Path) -> None: assert data == "9" +def test_saving_subclassed_datetime(tmp_path: Path) -> None: + """Test saving subclassed datetime objects.""" + + class SubClassDateTime(datetime.datetime): + """Subclass datetime.""" + + time = SubClassDateTime.fromtimestamp(0) + + fname = tmp_path / "test6.json" + save_json(fname, {"time": time}) + data = load_json(fname) + assert data == {"time": time.isoformat()} + + def test_default_encoder_is_passed(tmp_path: Path) -> None: """Test we use orjson if they pass in the default encoder.""" fname = tmp_path / "test6.json" diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 7f66ec25977..5163dd0ca6d 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,7 +9,6 @@ from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch -from async_timeout import timeout import pytest import voluptuous as vol @@ -1000,7 +999,7 @@ async def test_wait_basic_times_out(hass: HomeAssistant, action_type) -> None: assert script_obj.last_action == wait_alias hass.states.async_set("switch.test", "not_on") - async with timeout(0.1): + async with asyncio.timeout(0.1): await hass.async_block_till_done() except asyncio.TimeoutError: timed_out = True @@ -1386,7 +1385,7 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, second_non_matching_time) - async with timeout(0.1): + async with asyncio.timeout(0.1): await hass.async_block_till_done() except asyncio.TimeoutError: timed_out = True diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index c1d5f76ea78..590526cdb2b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -655,6 +655,11 @@ def test_text_selector_schema(schema, valid_selections, invalid_selections) -> N (["red"], ["green", "blue"], []), (0, None, "red"), ), + ( + {"options": ["red", "green", "blue"], "sort": True}, + ("red", "blue"), + (0, None, ["red"]), + ), ), ) def test_select_selector_schema(schema, valid_selections, invalid_selections) -> None: diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 56ee3f74140..803a57e12ed 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -11,7 +11,7 @@ import voluptuous as vol # To prevent circular import when running just this file from homeassistant import exceptions from homeassistant.auth.permissions import PolicyPermissions -import homeassistant.components # noqa: F401, pylint: disable=unused-import +import homeassistant.components # noqa: F401 from homeassistant.const import ( ATTR_ENTITY_ID, ENTITY_MATCH_ALL, diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 81953c7d785..85aa4d2de0e 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -60,6 +60,12 @@ def store_v_2_1(hass): ) +@pytest.fixture +def read_only_store(hass): + """Fixture of a read only store.""" + return storage.Store(hass, MOCK_VERSION, MOCK_KEY, read_only=True) + + async def test_loading(hass: HomeAssistant, store) -> None: """Test we can save and load data.""" await store.async_save(MOCK_DATA) @@ -703,3 +709,27 @@ async def test_os_error_is_fatal(tmpdir: py.path.local) -> None: await store.async_load() await hass.async_stop(force=True) + + +async def test_read_only_store( + hass: HomeAssistant, read_only_store, hass_storage: dict[str, Any] +) -> None: + """Test store opened in read only mode does not save.""" + read_only_store.async_delay_save(lambda: MOCK_DATA, 1) + assert read_only_store.key not in hass_storage + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert read_only_store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert read_only_store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + await hass.async_block_till_done() + assert read_only_store.key not in hass_storage diff --git a/tests/helpers/test_temperature.py b/tests/helpers/test_temperature.py index c4ab540f9d6..ceb4f7bdef2 100644 --- a/tests/helpers/test_temperature.py +++ b/tests/helpers/test_temperature.py @@ -5,8 +5,7 @@ from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.temperature import display_temp @@ -18,21 +17,21 @@ def test_temperature_not_a_number(hass: HomeAssistant) -> None: """Test that temperature is a number.""" temp = "Temperature" with pytest.raises(Exception) as exception: - display_temp(hass, temp, TEMP_CELSIUS, PRECISION_HALVES) + display_temp(hass, temp, UnitOfTemperature.CELSIUS, PRECISION_HALVES) assert f"Temperature is not a number: {temp}" in str(exception.value) def test_celsius_halves(hass: HomeAssistant) -> None: """Test temperature to celsius rounding to halves.""" - assert display_temp(hass, TEMP, TEMP_CELSIUS, PRECISION_HALVES) == 24.5 + assert display_temp(hass, TEMP, UnitOfTemperature.CELSIUS, PRECISION_HALVES) == 24.5 def test_celsius_tenths(hass: HomeAssistant) -> None: """Test temperature to celsius rounding to tenths.""" - assert display_temp(hass, TEMP, TEMP_CELSIUS, PRECISION_TENTHS) == 24.6 + assert display_temp(hass, TEMP, UnitOfTemperature.CELSIUS, PRECISION_TENTHS) == 24.6 def test_fahrenheit_wholes(hass: HomeAssistant) -> None: """Test temperature to fahrenheit rounding to wholes.""" - assert display_temp(hass, TEMP, TEMP_FAHRENHEIT, PRECISION_WHOLE) == -4 + assert display_temp(hass, TEMP, UnitOfTemperature.FAHRENHEIT, PRECISION_WHOLE) == -4 diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 0c3f0e4469a..58e0c730165 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -19,15 +19,15 @@ from homeassistant.components import group from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, - LENGTH_METERS, - LENGTH_MILLIMETERS, - MASS_GRAMS, STATE_ON, STATE_UNAVAILABLE, - TEMP_CELSIUS, VOLUME_LITERS, + UnitOfLength, + UnitOfMass, + UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -52,12 +52,12 @@ def _set_up_units(hass: HomeAssistant) -> None: """Set up the tests.""" hass.config.units = UnitSystem( "custom", - accumulated_precipitation=LENGTH_MILLIMETERS, + accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, conversions={}, - length=LENGTH_METERS, - mass=MASS_GRAMS, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, pressure=UnitOfPressure.PA, - temperature=TEMP_CELSIUS, + temperature=UnitOfTemperature.CELSIUS, volume=VOLUME_LITERS, wind_speed=UnitOfSpeed.KILOMETERS_PER_HOUR, ) @@ -2709,6 +2709,7 @@ async def test_device_entities( ) -> None: """Test device_entities function.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) # Test non existing device ids info = render_to_info(hass, "{{ device_entities('abc123') }}") @@ -2858,6 +2859,7 @@ async def test_device_id( ) -> None: """Test device_id function.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, @@ -2903,6 +2905,7 @@ async def test_device_attr( ) -> None: """Test device_attr and is_device_attr functions.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) # Test non existing device ids (device_attr) info = render_to_info(hass, "{{ device_attr('abc123', 'id') }}") @@ -3049,6 +3052,7 @@ async def test_area_id( ) -> None: """Test area_id function.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) # Test non existing entity id info = render_to_info(hass, "{{ area_id('sensor.fake') }}") @@ -3155,6 +3159,7 @@ async def test_area_name( ) -> None: """Test area_name function.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) # Test non existing entity id info = render_to_info(hass, "{{ area_name('sensor.fake') }}") @@ -3236,6 +3241,7 @@ async def test_area_entities( ) -> None: """Test area_entities function.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) # Test non existing device id info = render_to_info(hass, "{{ area_entities('deadbeef') }}") @@ -3290,6 +3296,7 @@ async def test_area_devices( ) -> None: """Test area_devices function.""" config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) # Test non existing device id info = render_to_info(hass, "{{ area_devices('deadbeef') }}") @@ -3437,7 +3444,7 @@ async def test_async_render_to_info_with_wildcard_matching_entity_id( template_complex_str = r""" {% for state in states.cover %} - {% if state.entity_id | regex_match('.*\.office_') %} + {% if state.entity_id | regex_match('.*\\.office_') %} {{ state.entity_id }}={{ state.state }} {% endif %} {% endfor %} @@ -4459,15 +4466,25 @@ async def test_parse_result(hass: HomeAssistant) -> None: assert template.Template(tpl, hass).async_render() == result -async def test_undefined_variable( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + "template_string", + [ + "{{ no_such_variable }}", + "{{ no_such_variable and True }}", + "{{ no_such_variable | join(', ') }}", + ], +) +async def test_undefined_symbol_warnings( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + template_string: str, ) -> None: """Test a warning is logged on undefined variables.""" - tpl = template.Template("{{ no_such_variable }}", hass) + tpl = template.Template(template_string, hass) assert tpl.async_render() == "" assert ( "Template variable warning: 'no_such_variable' is undefined when rendering " - "'{{ no_such_variable }}'" in caplog.text + f"'{template_string}'" in caplog.text ) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 91f761b5bb6..182ed6c3cb4 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch import urllib.error import aiohttp +from freezegun.api import FrozenDateTimeFactory import pytest import requests @@ -329,11 +330,14 @@ async def test_refresh_no_update_method( async def test_update_interval( - hass: HomeAssistant, crd: update_coordinator.DataUpdateCoordinator[int] + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + crd: update_coordinator.DataUpdateCoordinator[int], ) -> None: """Test update interval works.""" # Test we don't update without subscriber - async_fire_time_changed(hass, utcnow() + crd.update_interval) + freezer.tick(crd.update_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert crd.data is None @@ -342,18 +346,21 @@ async def test_update_interval( unsub = crd.async_add_listener(update_callback) # Test twice we update with subscriber - async_fire_time_changed(hass, utcnow() + crd.update_interval) + freezer.tick(crd.update_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert crd.data == 1 - async_fire_time_changed(hass, utcnow() + crd.update_interval) + freezer.tick(crd.update_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() assert crd.data == 2 # Test removing listener unsub() - async_fire_time_changed(hass, utcnow() + crd.update_interval) + freezer.tick(crd.update_interval) + async_fire_time_changed(hass) await hass.async_block_till_done() # Test we stop updating after we lose last subscriber @@ -594,3 +601,118 @@ async def test_async_set_update_error( # Remove callbacks to avoid lingering timers remove_callbacks() + + +async def test_only_callback_on_change_when_always_update_is_false( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: + """Test we do not callback listeners unless something has actually changed when always_update is false.""" + update_callback = Mock() + crd.always_update = False + remove_callbacks = crd.async_add_listener(update_callback) + mocked_data = None + mocked_exception = None + + async def _update_method() -> int: + nonlocal mocked_data + nonlocal mocked_exception + if mocked_exception is not None: + raise mocked_exception + return mocked_data + + crd.update_method = _update_method + + mocked_data = {"a": 1} + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + mocked_data = {"a": 1} + await crd.async_refresh() + update_callback.assert_not_called() + update_callback.reset_mock() + + mocked_data = None + mocked_exception = aiohttp.ClientError("Client Failure #1") + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + mocked_data = None + mocked_exception = aiohttp.ClientError("Client Failure #1") + await crd.async_refresh() + update_callback.assert_not_called() + update_callback.reset_mock() + + mocked_exception = None + mocked_data = {"a": 1} + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + mocked_data = {"a": 1} + await crd.async_refresh() + update_callback.assert_not_called() + update_callback.reset_mock() + + mocked_data = {"a": 2} + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + mocked_data = {"a": 2} + await crd.async_refresh() + update_callback.assert_not_called() + update_callback.reset_mock() + + mocked_data = {"a": 2, "b": 3} + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + remove_callbacks() + + +async def test_always_callback_when_always_update_is_true( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: + """Test we callback listeners even though the data is the same when always_update is True.""" + update_callback = Mock() + remove_callbacks = crd.async_add_listener(update_callback) + mocked_data = None + mocked_exception = None + + async def _update_method() -> int: + nonlocal mocked_data + nonlocal mocked_exception + if mocked_exception is not None: + raise mocked_exception + return mocked_data + + crd.update_method = _update_method + + mocked_data = {"a": 1} + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + mocked_data = {"a": 1} + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + # But still don't fire it if we are only getting + # failure over and over + mocked_data = None + mocked_exception = aiohttp.ClientError("Client Failure #1") + await crd.async_refresh() + update_callback.assert_called_once() + update_callback.reset_mock() + + mocked_data = None + mocked_exception = aiohttp.ClientError("Client Failure #1") + await crd.async_refresh() + update_callback.assert_not_called() + update_callback.reset_mock() + + remove_callbacks() diff --git a/tests/patch_time.py b/tests/patch_time.py new file mode 100644 index 00000000000..5f5dc467c9d --- /dev/null +++ b/tests/patch_time.py @@ -0,0 +1,23 @@ +"""Patch time related functions.""" +from __future__ import annotations + +import datetime +import time + +from homeassistant import runner, util +from homeassistant.util import dt as dt_util + + +def _utcnow() -> datetime.datetime: + """Make utcnow patchable by freezegun.""" + return datetime.datetime.now(datetime.UTC) + + +def _monotonic() -> float: + """Make monotonic patchable by freezegun.""" + return time.monotonic() + + +dt_util.utcnow = _utcnow # type: ignore[assignment] +util.utcnow = _utcnow # type: ignore[assignment] +runner.monotonic = _monotonic # type: ignore[assignment] diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index b80d8f01445..d23d5a849dd 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -108,7 +108,7 @@ def test_ignore_no_annotations( ) -> None: """Ensure that _is_valid_type is not run if there are no annotations.""" # Set ignore option - type_hint_checker.config.ignore_missing_annotations = True + type_hint_checker.linter.config.ignore_missing_annotations = True func_node = astroid.extract_node( code, @@ -143,7 +143,7 @@ def test_bypass_ignore_no_annotations( but `ignore-missing-annotations` option is forced to False. """ # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False func_node = astroid.extract_node( code, @@ -485,7 +485,7 @@ def test_invalid_entity_properties( ) -> None: """Check missing entity properties when ignore_missing_annotations is False.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node, prop_node, func_node = astroid.extract_node( """ @@ -552,7 +552,7 @@ def test_ignore_invalid_entity_properties( ) -> None: """Check invalid entity properties are ignored by default.""" # Set ignore option - type_hint_checker.config.ignore_missing_annotations = True + type_hint_checker.linter.config.ignore_missing_annotations = True class_node = astroid.extract_node( """ @@ -590,7 +590,7 @@ def test_named_arguments( ) -> None: """Check missing entity properties when ignore_missing_annotations is False.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node, func_node, percentage_node, preset_mode_node = astroid.extract_node( """ @@ -676,7 +676,7 @@ def test_invalid_mapping_return_type( ) -> None: """Check that Mapping[xxx, Any] doesn't accept invalid Mapping or dict.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node, property_node = astroid.extract_node( f""" @@ -734,7 +734,7 @@ def test_valid_mapping_return_type( ) -> None: """Check that Mapping[xxx, Any] accepts both Mapping and dict.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node = astroid.extract_node( f""" @@ -774,7 +774,7 @@ def test_valid_long_tuple( ) -> None: """Check invalid entity properties are ignored by default.""" # Set ignore option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node, _, _, _ = astroid.extract_node( """ @@ -821,7 +821,7 @@ def test_invalid_long_tuple( ) -> None: """Check invalid entity properties are ignored by default.""" # Set ignore option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node, rgbw_node, rgbww_node = astroid.extract_node( """ @@ -882,7 +882,7 @@ def test_invalid_device_class( ) -> None: """Ensure invalid hints are rejected for entity device_class.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node, prop_node = astroid.extract_node( """ @@ -925,7 +925,7 @@ def test_media_player_entity( ) -> None: """Ensure valid hints are accepted for media_player entity.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False class_node = astroid.extract_node( """ @@ -952,7 +952,7 @@ def test_media_player_entity( def test_number_entity(linter: UnittestLinter, type_hint_checker: BaseChecker) -> None: """Ensure valid hints are accepted for number entity.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False # Ensure that device class is valid despite Entity inheritance # Ensure that `int` is valid for `float` return type @@ -989,7 +989,7 @@ def test_number_entity(linter: UnittestLinter, type_hint_checker: BaseChecker) - def test_vacuum_entity(linter: UnittestLinter, type_hint_checker: BaseChecker) -> None: """Ensure valid hints are accepted for vacuum entity.""" # Set bypass option - type_hint_checker.config.ignore_missing_annotations = False + type_hint_checker.linter.config.ignore_missing_annotations = False # Ensure that `dict | list | None` is valid for params class_node = astroid.extract_node( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 44a4d55d545..e410dd672ce 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -117,6 +117,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", + "use_x_frame_options": True, } assert res["secret_cache"] == { get_test_config_dir("secrets.yaml"): {"http_pw": "http://google.com"} diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 26eef47273f..ea9e04ac993 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -23,7 +23,6 @@ from .common import ( MockModule, MockPlatform, get_test_config_dir, - mock_coro, mock_entity_platform, mock_integration, ) @@ -110,7 +109,7 @@ async def test_core_failure_loads_safe_mode( """Test failing core setup aborts further setup.""" with patch( "homeassistant.components.homeassistant.async_setup", - return_value=mock_coro(False), + return_value=False, ): await bootstrap.async_from_config_dict({"group": {}}, hass) diff --git a/tests/test_config.py b/tests/test_config.py index 407ca9ef54d..aeb25313302 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -311,7 +311,6 @@ def test_remove_lib_on_upgrade( mock_open = mock.mock_open() with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version hass.config.path = mock.Mock() config_util.process_ha_config_upgrade(hass) @@ -335,7 +334,6 @@ def test_remove_lib_on_upgrade_94( mock_open = mock.mock_open() with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version hass.config.path = mock.Mock() config_util.process_ha_config_upgrade(hass) @@ -356,7 +354,6 @@ def test_process_config_upgrade(hass: HomeAssistant) -> None: config_util, "__version__", "0.91.0" ): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version config_util.process_ha_config_upgrade(hass) @@ -372,7 +369,6 @@ def test_config_upgrade_same_version(hass: HomeAssistant) -> None: mock_open = mock.mock_open() with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version config_util.process_ha_config_upgrade(hass) @@ -386,7 +382,6 @@ def test_config_upgrade_no_file(hass: HomeAssistant) -> None: mock_open.side_effect = [FileNotFoundError(), mock.DEFAULT, mock.DEFAULT] with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member config_util.process_ha_config_upgrade(hass) assert opened_file.write.call_count == 1 assert opened_file.write.call_args == mock.call(__version__) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 68e6fc59987..760c7138c88 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,7 +40,6 @@ from .common import ( MockPlatform, async_fire_time_changed, mock_config_flow, - mock_coro, mock_entity_platform, mock_integration, ) @@ -605,7 +604,10 @@ async def test_domains_gets_domains_excludes_ignore_and_disabled( async def test_saving_and_loading(hass: HomeAssistant) -> None: """Test that we're saving and loading correctly.""" mock_integration( - hass, MockModule("test", async_setup_entry=lambda *args: mock_coro(True)) + hass, + MockModule( + "test", async_setup_entry=lambda *args: AsyncMock(return_value=True) + ), ) mock_entity_platform(hass, "config_flow.test", None) @@ -1178,6 +1180,27 @@ async def test_entry_options_abort( ) +async def test_entry_options_unknown_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can abort options flow.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + class TestFlow: + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + with pytest.raises(config_entries.UnknownEntry): + await manager.options.async_create_flow( + "blah", context={"source": "test"}, data=None + ) + + async def test_entry_setup_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1544,6 +1567,28 @@ async def test_init_custom_integration(hass: HomeAssistant) -> None: await hass.config_entries.flow.async_init("bla", context={"source": "user"}) +async def test_init_custom_integration_with_missing_handler( + hass: HomeAssistant, +) -> None: + """Test initializing flow for custom integration with a missing handler.""" + integration = loader.Integration( + hass, + "custom_components.hue", + None, + {"name": "Hue", "dependencies": [], "requirements": [], "domain": "hue"}, + ) + mock_integration( + hass, + MockModule("hue"), + ) + mock_entity_platform(hass, "config_flow.hue", None) + with pytest.raises(data_entry_flow.UnknownHandler), patch( + "homeassistant.loader.async_get_integration", + return_value=integration, + ): + await hass.config_entries.flow.async_init("bla", context={"source": "user"}) + + async def test_support_entry_unload(hass: HomeAssistant) -> None: """Test unloading entry.""" assert await config_entries.support_entry_unload(hass, "light") @@ -3336,11 +3381,13 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: ({"vendor": "zoo"}, "already_configured"), ({"ip": "9.9.9.9"}, "already_configured"), ({"ip": "7.7.7.7"}, "no_match"), # ignored - ({"vendor": "data"}, "no_match"), + # The next two data sets ensure options or data match + # as options previously shadowed data when matching. + ({"vendor": "data"}, "already_configured"), ( {"vendor": "options"}, "already_configured", - ), # ensure options takes precedence over data + ), ], ) async def test__async_abort_entries_match( @@ -3417,11 +3464,13 @@ async def test__async_abort_entries_match( ({"vendor": "zoo"}, "already_configured"), ({"ip": "9.9.9.9"}, "already_configured"), ({"ip": "7.7.7.7"}, "no_match"), # ignored - ({"vendor": "data"}, "no_match"), + # The next two data sets ensure options or data match + # as options previously shadowed data when matching. + ({"vendor": "data"}, "already_configured"), ( {"vendor": "options"}, "already_configured", - ), # ensure options takes precedence over data + ), ], ) async def test__async_abort_entries_match_options_flow( @@ -3897,3 +3946,74 @@ async def test_task_tracking(hass: HomeAssistant) -> None: hass.loop.call_soon(event.set) await entry._async_process_on_unload(hass) assert results == ["on_unload", "background", "normal"] + + +async def test_preview_supported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test preview support.""" + + preview_calls = [] + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_test1(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="next") + + async def async_step_test2(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="next", preview="test") + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + preview_calls.append(None) + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + assert len(preview_calls) == 0 + + with patch.dict( + config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} + ): + result = await manager.flow.async_init("test", context={"source": "test1"}) + + assert len(preview_calls) == 0 + assert result["preview"] is None + + result = await manager.flow.async_init("test", context={"source": "test2"}) + + assert len(preview_calls) == 1 + assert result["preview"] == "test" + + +async def test_preview_not_supported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test preview support.""" + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_user(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="user_confirm") + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + with patch.dict( + config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} + ): + result = await manager.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER} + ) + + assert result["preview"] is None diff --git a/tests/test_core.py b/tests/test_core.py index 7e0766c8ac5..8ec4dad2ebd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,7 +14,6 @@ import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch -import async_timeout import pytest import voluptuous as vol @@ -235,7 +234,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: assert can_call_async_get_hass() hass.async_create_task(_async_create_task(), "create_task") - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -246,7 +245,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: task_finished.set() hass.async_add_job(_add_job) - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -263,7 +262,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.async_add_job(_callback) _schedule_callback_from_callback() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -279,7 +278,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.async_add_job(_coroutine()) _schedule_coroutine_from_callback() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -295,7 +294,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.async_add_job(_callback) await _schedule_callback_from_coroutine() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -310,7 +309,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: await hass.async_create_task(_coroutine()) await _schedule_callback_from_coroutine() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -326,7 +325,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.add_job(_async_add_job) await hass.async_add_executor_job(_async_add_executor_job_add_job) - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -341,7 +340,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: hass.create_task(_async_create_task()) await hass.async_add_executor_job(_async_add_executor_job_create_task) - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() @@ -359,7 +358,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: my_job_add_job = MyJobAddJob() my_job_add_job.start() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() my_job_add_job.join() @@ -377,7 +376,7 @@ async def test_async_get_hass_can_be_called(hass: HomeAssistant) -> None: my_job_create_task = MyJobCreateTask() my_job_create_task.start() - async with async_timeout.timeout(1): + async with asyncio.timeout(1): await task_finished.wait() task_finished.clear() my_job_create_task.join() @@ -1247,7 +1246,7 @@ async def test_serviceregistry_async_service_raise_exception( await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) # Non-blocking service call never throw exception - hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) await hass.async_block_till_done() @@ -1267,7 +1266,7 @@ async def test_serviceregistry_callback_service_raise_exception( await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=True) # Non-blocking service call never throw exception - hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) + await hass.services.async_call("test_domain", "REGISTER_CALLS", blocking=False) await hass.async_block_till_done() @@ -1429,7 +1428,7 @@ async def test_serviceregistry_return_response_optional( async def test_config_defaults() -> None: """Test config defaults.""" hass = Mock() - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") assert config.hass is hass assert config.latitude == 0 assert config.longitude == 0 @@ -1443,7 +1442,7 @@ async def test_config_defaults() -> None: assert config.skip_pip_packages == [] assert config.components == set() assert config.api is None - assert config.config_dir is None + assert config.config_dir == "/test/ha-config" assert config.allowlist_external_dirs == set() assert config.allowlist_external_urls == set() assert config.media_dirs == {} @@ -1456,22 +1455,19 @@ async def test_config_defaults() -> None: async def test_config_path_with_file() -> None: """Test get_config_path method.""" - config = ha.Config(None) - config.config_dir = "/test/ha-config" + config = ha.Config(None, "/test/ha-config") assert config.path("test.conf") == "/test/ha-config/test.conf" async def test_config_path_with_dir_and_file() -> None: """Test get_config_path method.""" - config = ha.Config(None) - config.config_dir = "/test/ha-config" + config = ha.Config(None, "/test/ha-config") assert config.path("dir", "test.conf") == "/test/ha-config/dir/test.conf" async def test_config_as_dict() -> None: """Test as dict.""" - config = ha.Config(None) - config.config_dir = "/test/ha-config" + config = ha.Config(None, "/test/ha-config") config.hass = MagicMock() type(config.hass.state).value = PropertyMock(return_value="RUNNING") expected = { @@ -1502,7 +1498,7 @@ async def test_config_as_dict() -> None: async def test_config_is_allowed_path() -> None: """Test is_allowed_path method.""" - config = ha.Config(None) + config = ha.Config(None, "/test/ha-config") with TemporaryDirectory() as tmp_dir: # The created dir is in /tmp. This is a symlink on OS X # causing this test to fail unless we resolve path first. @@ -1534,7 +1530,7 @@ async def test_config_is_allowed_path() -> None: async def test_config_is_allowed_external_url() -> None: """Test is_allowed_external_url method.""" - config = ha.Config(None) + config = ha.Config(None, "/test/ha-config") config.allowlist_external_urls = [ "http://x.com/", "https://y.com/bla/", @@ -1585,7 +1581,7 @@ async def test_start_taking_too_long( event_loop, caplog: pytest.LogCaptureFixture ) -> None: """Test when async_start takes too long.""" - hass = ha.HomeAssistant() + hass = ha.HomeAssistant("/test/ha-config") caplog.set_level(logging.WARNING) hass.async_create_task(asyncio.sleep(0)) @@ -1752,7 +1748,7 @@ async def test_additional_data_in_core_config( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test that we can handle additional data in core configuration.""" - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, "data": {"location_name": "Test Name", "additional_valid_key": "value"}, @@ -1765,7 +1761,7 @@ async def test_incorrect_internal_external_url( hass: HomeAssistant, hass_storage: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: """Test that we warn when detecting invalid internal/external url.""" - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, @@ -1778,7 +1774,7 @@ async def test_incorrect_internal_external_url( assert "Invalid external_url set" not in caplog.text assert "Invalid internal_url set" not in caplog.text - config = ha.Config(hass) + config = ha.Config(hass, "/test/ha-config") hass_storage[ha.CORE_STORAGE_KEY] = { "version": 1, @@ -2468,3 +2464,10 @@ async def test_cancellable_hassjob(hass: HomeAssistant) -> None: # Cleanup timer2.cancel() + + +async def test_validate_state(hass: HomeAssistant) -> None: + """Test validate_state.""" + assert ha.validate_state("test") == "test" + with pytest.raises(InvalidStateError): + ha.validate_state("t" * 256) diff --git a/tests/test_runner.py b/tests/test_runner.py index f32321c578c..5fe5c2881ff 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -1,8 +1,10 @@ """Test the runner.""" import asyncio +from collections.abc import Iterator import threading from unittest.mock import patch +import packaging.tags import py import pytest @@ -147,19 +149,22 @@ async def test_unhandled_exception_traceback( def test__enable_posix_spawn() -> None: - """Test that we can enable posix_spawn on Alpine.""" + """Test that we can enable posix_spawn on musllinux.""" - def _mock_alpine_exists(path): - return path == "/etc/alpine-release" + def _mock_sys_tags_any() -> Iterator[packaging.tags.Tag]: + yield from packaging.tags.parse_tag("py3-none-any") - with patch.object(runner.subprocess, "_USE_POSIX_SPAWN", False), patch.object( - runner.os.path, "exists", _mock_alpine_exists + def _mock_sys_tags_musl() -> Iterator[packaging.tags.Tag]: + yield from packaging.tags.parse_tag("cp311-cp311-musllinux_1_1_x86_64") + + with patch.object(runner.subprocess, "_USE_POSIX_SPAWN", False), patch( + "homeassistant.runner.packaging.tags.sys_tags", side_effect=_mock_sys_tags_musl ): runner._enable_posix_spawn() assert runner.subprocess._USE_POSIX_SPAWN is True - with patch.object(runner.subprocess, "_USE_POSIX_SPAWN", False), patch.object( - runner.os.path, "exists", return_value=False + with patch.object(runner.subprocess, "_USE_POSIX_SPAWN", False), patch( + "homeassistant.runner.packaging.tags.sys_tags", side_effect=_mock_sys_tags_any ): runner._enable_posix_spawn() assert runner.subprocess._USE_POSIX_SPAWN is False diff --git a/tests/testing_config/custom_components/test/datetime.py b/tests/testing_config/custom_components/test/datetime.py index 7fca8d57881..ba511e81648 100644 --- a/tests/testing_config/custom_components/test/datetime.py +++ b/tests/testing_config/custom_components/test/datetime.py @@ -2,7 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.components.datetime import DateTimeEntity @@ -37,7 +37,7 @@ def init(empty=False): MockDateTimeEntity( name="test", unique_id=UNIQUE_DATETIME, - native_value=datetime(2020, 1, 1, 1, 2, 3, tzinfo=timezone.utc), + native_value=datetime(2020, 1, 1, 1, 2, 3, tzinfo=UTC), ), ] ) diff --git a/tests/testing_config/custom_components/test/update.py b/tests/testing_config/custom_components/test/update.py index 5d2292e9249..36b4e7c692f 100644 --- a/tests/testing_config/custom_components/test/update.py +++ b/tests/testing_config/custom_components/test/update.py @@ -61,7 +61,7 @@ class MockUpdateEntity(MockEntity, UpdateEntity): if version is not None: self._values["installed_version"] = version - _LOGGER.info(f"Installed update with version: {version}") + _LOGGER.info("Installed update with version: %s", version) else: self._values["installed_version"] = self.latest_version _LOGGER.info("Installed latest update") diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index e2d026ec840..84864c1dbb2 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -18,13 +18,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRESSURE, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, Forecast, WeatherEntity, ) @@ -298,19 +293,32 @@ class MockWeatherMockForecast(MockWeather): ] -class MockWeatherMockForecastCompat(MockWeatherCompat): - """Mock weather class with mocked forecast for compatibility check.""" +class MockWeatherMockLegacyForecastOnly(MockWeather): + """Mock weather class with mocked legacy forecast.""" + + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" - return [ - { - ATTR_FORECAST_TEMP: self.temperature, - ATTR_FORECAST_TEMP_LOW: self.temperature, - ATTR_FORECAST_PRESSURE: self.pressure, - ATTR_FORECAST_WIND_SPEED: self.wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_PRECIPITATION: self._values.get("precipitation"), - } - ] + return self.forecast_list diff --git a/tests/testing_config/custom_components/test_weather/__init__.py b/tests/testing_config/custom_components/test_weather/__init__.py new file mode 100644 index 00000000000..ddec081ed8b --- /dev/null +++ b/tests/testing_config/custom_components/test_weather/__init__.py @@ -0,0 +1 @@ +"""An integration with Weather platform.""" diff --git a/tests/testing_config/custom_components/test_weather/manifest.json b/tests/testing_config/custom_components/test_weather/manifest.json new file mode 100644 index 00000000000..d1238659b41 --- /dev/null +++ b/tests/testing_config/custom_components/test_weather/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "test_weather", + "name": "Test Weather", + "documentation": "http://example.com", + "requirements": [], + "dependencies": [], + "codeowners": [], + "version": "1.2.3" +} diff --git a/tests/testing_config/custom_components/test_weather/weather.py b/tests/testing_config/custom_components/test_weather/weather.py new file mode 100644 index 00000000000..68d9ccab712 --- /dev/null +++ b/tests/testing_config/custom_components/test_weather/weather.py @@ -0,0 +1,210 @@ +"""Provide a mock weather platform. + +Call init before using it in your tests to ensure clean test data. +""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.weather import ( + ATTR_FORECAST_CLOUD_COVERAGE, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, + Forecast, + WeatherEntity, +) + +from tests.common import MockEntity + +ENTITIES = [] + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + ENTITIES = [] if empty else [MockWeatherMockForecast()] + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) + + +class MockWeatherMockForecast(MockEntity, WeatherEntity): + """Mock weather class.""" + + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the forecast_twice_daily.""" + return [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + ATTR_FORECAST_IS_DAYTIME: self._values.get("is_daytime"), + } + ] + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the forecast_hourly.""" + return [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + + @property + def native_temperature(self) -> float | None: + """Return the platform temperature.""" + return self._handle("native_temperature") + + @property + def native_apparent_temperature(self) -> float | None: + """Return the platform apparent temperature.""" + return self._handle("native_apparent_temperature") + + @property + def native_dew_point(self) -> float | None: + """Return the platform dewpoint temperature.""" + return self._handle("native_dew_point") + + @property + def native_temperature_unit(self) -> str | None: + """Return the unit of measurement for temperature.""" + return self._handle("native_temperature_unit") + + @property + def native_pressure(self) -> float | None: + """Return the pressure.""" + return self._handle("native_pressure") + + @property + def native_pressure_unit(self) -> str | None: + """Return the unit of measurement for pressure.""" + return self._handle("native_pressure_unit") + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return self._handle("humidity") + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("native_wind_gust_speed") + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("native_wind_speed") + + @property + def native_wind_speed_unit(self) -> str | None: + """Return the unit of measurement for wind speed.""" + return self._handle("native_wind_speed_unit") + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return self._handle("wind_bearing") + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return self._handle("ozone") + + @property + def cloud_coverage(self) -> float | None: + """Return the cloud coverage in %.""" + return self._handle("cloud_coverage") + + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._handle("uv_index") + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return self._handle("native_visibility") + + @property + def native_visibility_unit(self) -> str | None: + """Return the unit of measurement for visibility.""" + return self._handle("native_visibility_unit") + + @property + def native_precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._handle("native_precipitation_unit") + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._handle("condition") diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 60b1fd547fc..7c5e959aabc 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -31,7 +31,6 @@ GAMUT_INVALID_4 = color_util.GamutType( ) -# pylint: disable=invalid-name def test_color_RGB_to_xy_brightness() -> None: """Test color_RGB_to_xy_brightness.""" assert color_util.color_RGB_to_xy_brightness(0, 0, 0) == (0, 0, 0) diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py index d0b4bbc1ebe..c6a9d59cb73 100644 --- a/tests/util/test_distance.py +++ b/tests/util/test_distance.py @@ -2,39 +2,38 @@ import pytest -from homeassistant.const import ( - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, -) +from homeassistant.const import UnitOfLength from homeassistant.exceptions import HomeAssistantError import homeassistant.util.distance as distance_util INVALID_SYMBOL = "bob" -VALID_SYMBOL = LENGTH_KILOMETERS +VALID_SYMBOL = UnitOfLength.KILOMETERS def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: """Ensure that a warning is raised on use of convert.""" - assert distance_util.convert(2, LENGTH_METERS, LENGTH_METERS) == 2 + assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 assert "use unit_conversion.DistanceConverter instead" in caplog.text def test_convert_same_unit() -> None: """Test conversion from any unit to same unit.""" - assert distance_util.convert(5, LENGTH_KILOMETERS, LENGTH_KILOMETERS) == 5 - assert distance_util.convert(2, LENGTH_METERS, LENGTH_METERS) == 2 - assert distance_util.convert(6, LENGTH_CENTIMETERS, LENGTH_CENTIMETERS) == 6 - assert distance_util.convert(3, LENGTH_MILLIMETERS, LENGTH_MILLIMETERS) == 3 - assert distance_util.convert(10, LENGTH_MILES, LENGTH_MILES) == 10 - assert distance_util.convert(9, LENGTH_YARD, LENGTH_YARD) == 9 - assert distance_util.convert(8, LENGTH_FEET, LENGTH_FEET) == 8 - assert distance_util.convert(7, LENGTH_INCHES, LENGTH_INCHES) == 7 + assert ( + distance_util.convert(5, UnitOfLength.KILOMETERS, UnitOfLength.KILOMETERS) == 5 + ) + assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 + assert ( + distance_util.convert(6, UnitOfLength.CENTIMETERS, UnitOfLength.CENTIMETERS) + == 6 + ) + assert ( + distance_util.convert(3, UnitOfLength.MILLIMETERS, UnitOfLength.MILLIMETERS) + == 3 + ) + assert distance_util.convert(10, UnitOfLength.MILES, UnitOfLength.MILES) == 10 + assert distance_util.convert(9, UnitOfLength.YARDS, UnitOfLength.YARDS) == 9 + assert distance_util.convert(8, UnitOfLength.FEET, UnitOfLength.FEET) == 8 + assert distance_util.convert(7, UnitOfLength.INCHES, UnitOfLength.INCHES) == 7 def test_convert_invalid_unit() -> None: @@ -49,133 +48,145 @@ def test_convert_invalid_unit() -> None: def test_convert_nonnumeric_value() -> None: """Test exception is thrown for nonnumeric type.""" with pytest.raises(TypeError): - distance_util.convert("a", LENGTH_KILOMETERS, LENGTH_METERS) + distance_util.convert("a", UnitOfLength.KILOMETERS, UnitOfLength.METERS) @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_KILOMETERS, 8.04672), - (LENGTH_METERS, 8046.72), - (LENGTH_CENTIMETERS, 804672.0), - (LENGTH_MILLIMETERS, 8046720.0), - (LENGTH_YARD, 8800.0), - (LENGTH_FEET, 26400.0008448), - (LENGTH_INCHES, 316800.171072), + (UnitOfLength.KILOMETERS, 8.04672), + (UnitOfLength.METERS, 8046.72), + (UnitOfLength.CENTIMETERS, 804672.0), + (UnitOfLength.MILLIMETERS, 8046720.0), + (UnitOfLength.YARDS, 8800.0), + (UnitOfLength.FEET, 26400.0008448), + (UnitOfLength.INCHES, 316800.171072), ], ) def test_convert_from_miles(unit, expected) -> None: """Test conversion from miles to other units.""" miles = 5 - assert distance_util.convert(miles, LENGTH_MILES, unit) == pytest.approx(expected) + assert distance_util.convert(miles, UnitOfLength.MILES, unit) == pytest.approx( + expected + ) @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_KILOMETERS, 0.0045720000000000005), - (LENGTH_METERS, 4.572), - (LENGTH_CENTIMETERS, 457.2), - (LENGTH_MILLIMETERS, 4572), - (LENGTH_MILES, 0.002840908212), - (LENGTH_FEET, 15.00000048), - (LENGTH_INCHES, 180.0000972), + (UnitOfLength.KILOMETERS, 0.0045720000000000005), + (UnitOfLength.METERS, 4.572), + (UnitOfLength.CENTIMETERS, 457.2), + (UnitOfLength.MILLIMETERS, 4572), + (UnitOfLength.MILES, 0.002840908212), + (UnitOfLength.FEET, 15.00000048), + (UnitOfLength.INCHES, 180.0000972), ], ) def test_convert_from_yards(unit, expected) -> None: """Test conversion from yards to other units.""" yards = 5 - assert distance_util.convert(yards, LENGTH_YARD, unit) == pytest.approx(expected) + assert distance_util.convert(yards, UnitOfLength.YARDS, unit) == pytest.approx( + expected + ) @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_KILOMETERS, 1.524), - (LENGTH_METERS, 1524), - (LENGTH_CENTIMETERS, 152400.0), - (LENGTH_MILLIMETERS, 1524000.0), - (LENGTH_MILES, 0.9469694040000001), - (LENGTH_YARD, 1666.66667), - (LENGTH_INCHES, 60000.032400000004), + (UnitOfLength.KILOMETERS, 1.524), + (UnitOfLength.METERS, 1524), + (UnitOfLength.CENTIMETERS, 152400.0), + (UnitOfLength.MILLIMETERS, 1524000.0), + (UnitOfLength.MILES, 0.9469694040000001), + (UnitOfLength.YARDS, 1666.66667), + (UnitOfLength.INCHES, 60000.032400000004), ], ) def test_convert_from_feet(unit, expected) -> None: """Test conversion from feet to other units.""" feet = 5000 - assert distance_util.convert(feet, LENGTH_FEET, unit) == pytest.approx(expected) + assert distance_util.convert(feet, UnitOfLength.FEET, unit) == pytest.approx( + expected + ) @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_KILOMETERS, 0.127), - (LENGTH_METERS, 127.0), - (LENGTH_CENTIMETERS, 12700.0), - (LENGTH_MILLIMETERS, 127000.0), - (LENGTH_MILES, 0.078914117), - (LENGTH_YARD, 138.88889), - (LENGTH_FEET, 416.66668), + (UnitOfLength.KILOMETERS, 0.127), + (UnitOfLength.METERS, 127.0), + (UnitOfLength.CENTIMETERS, 12700.0), + (UnitOfLength.MILLIMETERS, 127000.0), + (UnitOfLength.MILES, 0.078914117), + (UnitOfLength.YARDS, 138.88889), + (UnitOfLength.FEET, 416.66668), ], ) def test_convert_from_inches(unit, expected) -> None: """Test conversion from inches to other units.""" inches = 5000 - assert distance_util.convert(inches, LENGTH_INCHES, unit) == pytest.approx(expected) + assert distance_util.convert(inches, UnitOfLength.INCHES, unit) == pytest.approx( + expected + ) @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_METERS, 5000), - (LENGTH_CENTIMETERS, 500000), - (LENGTH_MILLIMETERS, 5000000), - (LENGTH_MILES, 3.106855), - (LENGTH_YARD, 5468.066), - (LENGTH_FEET, 16404.2), - (LENGTH_INCHES, 196850.5), + (UnitOfLength.METERS, 5000), + (UnitOfLength.CENTIMETERS, 500000), + (UnitOfLength.MILLIMETERS, 5000000), + (UnitOfLength.MILES, 3.106855), + (UnitOfLength.YARDS, 5468.066), + (UnitOfLength.FEET, 16404.2), + (UnitOfLength.INCHES, 196850.5), ], ) def test_convert_from_kilometers(unit, expected) -> None: """Test conversion from kilometers to other units.""" km = 5 - assert distance_util.convert(km, LENGTH_KILOMETERS, unit) == pytest.approx(expected) + assert distance_util.convert(km, UnitOfLength.KILOMETERS, unit) == pytest.approx( + expected + ) @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_KILOMETERS, 5), - (LENGTH_CENTIMETERS, 500000), - (LENGTH_MILLIMETERS, 5000000), - (LENGTH_MILES, 3.106855), - (LENGTH_YARD, 5468.066), - (LENGTH_FEET, 16404.2), - (LENGTH_INCHES, 196850.5), + (UnitOfLength.KILOMETERS, 5), + (UnitOfLength.CENTIMETERS, 500000), + (UnitOfLength.MILLIMETERS, 5000000), + (UnitOfLength.MILES, 3.106855), + (UnitOfLength.YARDS, 5468.066), + (UnitOfLength.FEET, 16404.2), + (UnitOfLength.INCHES, 196850.5), ], ) def test_convert_from_meters(unit, expected) -> None: """Test conversion from meters to other units.""" m = 5000 - assert distance_util.convert(m, LENGTH_METERS, unit) == pytest.approx(expected) + assert distance_util.convert(m, UnitOfLength.METERS, unit) == pytest.approx( + expected + ) @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_KILOMETERS, 5), - (LENGTH_METERS, 5000), - (LENGTH_MILLIMETERS, 5000000), - (LENGTH_MILES, 3.106855), - (LENGTH_YARD, 5468.066), - (LENGTH_FEET, 16404.2), - (LENGTH_INCHES, 196850.5), + (UnitOfLength.KILOMETERS, 5), + (UnitOfLength.METERS, 5000), + (UnitOfLength.MILLIMETERS, 5000000), + (UnitOfLength.MILES, 3.106855), + (UnitOfLength.YARDS, 5468.066), + (UnitOfLength.FEET, 16404.2), + (UnitOfLength.INCHES, 196850.5), ], ) def test_convert_from_centimeters(unit, expected) -> None: """Test conversion from centimeters to other units.""" cm = 500000 - assert distance_util.convert(cm, LENGTH_CENTIMETERS, unit) == pytest.approx( + assert distance_util.convert(cm, UnitOfLength.CENTIMETERS, unit) == pytest.approx( expected ) @@ -183,18 +194,18 @@ def test_convert_from_centimeters(unit, expected) -> None: @pytest.mark.parametrize( ("unit", "expected"), [ - (LENGTH_KILOMETERS, 5), - (LENGTH_METERS, 5000), - (LENGTH_CENTIMETERS, 500000), - (LENGTH_MILES, 3.106855), - (LENGTH_YARD, 5468.066), - (LENGTH_FEET, 16404.2), - (LENGTH_INCHES, 196850.5), + (UnitOfLength.KILOMETERS, 5), + (UnitOfLength.METERS, 5000), + (UnitOfLength.CENTIMETERS, 500000), + (UnitOfLength.MILES, 3.106855), + (UnitOfLength.YARDS, 5468.066), + (UnitOfLength.FEET, 16404.2), + (UnitOfLength.INCHES, 196850.5), ], ) def test_convert_from_millimeters(unit, expected) -> None: """Test conversion from millimeters to other units.""" mm = 5000000 - assert distance_util.convert(mm, LENGTH_MILLIMETERS, unit) == pytest.approx( + assert distance_util.convert(mm, UnitOfLength.MILLIMETERS, unit) == pytest.approx( expected ) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index e9cde21a265..28695a94400 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -1,7 +1,7 @@ """Test Home Assistant date util methods.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import time import pytest @@ -41,9 +41,9 @@ def test_set_default_time_zone() -> None: def test_utcnow() -> None: """Test the UTC now method.""" - assert abs(dt_util.utcnow().replace(tzinfo=None) - datetime.utcnow()) < timedelta( - seconds=1 - ) + assert abs( + dt_util.utcnow().replace(tzinfo=None) - datetime.now(UTC).replace(tzinfo=None) + ) < timedelta(seconds=1) def test_now() -> None: @@ -51,13 +51,14 @@ def test_now() -> None: dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE)) assert abs( - dt_util.as_utc(dt_util.now()).replace(tzinfo=None) - datetime.utcnow() + dt_util.as_utc(dt_util.now()).replace(tzinfo=None) + - datetime.now(UTC).replace(tzinfo=None) ) < timedelta(seconds=1) def test_as_utc_with_naive_object() -> None: """Test the now method.""" - utcnow = datetime.utcnow() + utcnow = datetime.now(UTC).replace(tzinfo=None) assert utcnow == dt_util.as_utc(utcnow).replace(tzinfo=None) @@ -82,7 +83,9 @@ def test_as_utc_with_local_object() -> None: def test_as_local_with_naive_object() -> None: """Test local time with native object.""" now = dt_util.now() - assert abs(now - dt_util.as_local(datetime.utcnow())) < timedelta(seconds=1) + assert abs( + now - dt_util.as_local(datetime.now(UTC).replace(tzinfo=None)) + ) < timedelta(seconds=1) def test_as_local_with_local_object() -> None: diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 2a190d2aea5..ff26cba0dd4 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -1,12 +1,12 @@ """Test Home Assistant package util methods.""" import asyncio +from importlib.metadata import PackageNotFoundError, metadata import logging import os from subprocess import PIPE import sys from unittest.mock import MagicMock, call, patch -import pkg_resources import pytest import homeassistant.util.package as package @@ -246,9 +246,9 @@ async def test_async_get_user_site(mock_env_copy) -> None: def test_check_package_global() -> None: """Test for an installed package.""" - first_package = list(pkg_resources.working_set)[0] - installed_package = first_package.project_name - installed_version = first_package.version + pkg = metadata("homeassistant") + installed_package = pkg["name"] + installed_version = pkg["version"] assert package.is_installed(installed_package) assert package.is_installed(f"{installed_package}=={installed_version}") @@ -264,13 +264,13 @@ def test_check_package_zip() -> None: def test_get_distribution_falls_back_to_version() -> None: """Test for get_distribution failing and fallback to version.""" - first_package = list(pkg_resources.working_set)[0] - installed_package = first_package.project_name - installed_version = first_package.version + pkg = metadata("homeassistant") + installed_package = pkg["name"] + installed_version = pkg["version"] with patch( - "homeassistant.util.package.pkg_resources.get_distribution", - side_effect=pkg_resources.ExtractionError, + "homeassistant.util.package.distribution", + side_effect=PackageNotFoundError, ): assert package.is_installed(installed_package) assert package.is_installed(f"{installed_package}=={installed_version}") @@ -281,13 +281,13 @@ def test_get_distribution_falls_back_to_version() -> None: def test_check_package_previous_failed_install() -> None: """Test for when a previously install package failed and left cruft behind.""" - first_package = list(pkg_resources.working_set)[0] - installed_package = first_package.project_name - installed_version = first_package.version + pkg = metadata("homeassistant") + installed_package = pkg["name"] + installed_version = pkg["version"] with patch( - "homeassistant.util.package.pkg_resources.get_distribution", - side_effect=pkg_resources.ExtractionError, + "homeassistant.util.package.distribution", + side_effect=PackageNotFoundError, ), 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_temperature.py b/tests/util/test_temperature.py index d1b1a9fbb11..93edb8f7393 100644 --- a/tests/util/test_temperature.py +++ b/tests/util/test_temperature.py @@ -1,17 +1,22 @@ """Test Home Assistant temperature utility functions.""" import pytest -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN +from homeassistant.const import UnitOfTemperature from homeassistant.exceptions import HomeAssistantError import homeassistant.util.temperature as temperature_util INVALID_SYMBOL = "bob" -VALID_SYMBOL = TEMP_CELSIUS +VALID_SYMBOL = UnitOfTemperature.CELSIUS def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: """Ensure that a warning is raised on use of convert.""" - assert temperature_util.convert(2, TEMP_CELSIUS, TEMP_CELSIUS) == 2 + assert ( + temperature_util.convert( + 2, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS + ) + == 2 + ) assert "use unit_conversion.TemperatureConverter instead" in caplog.text @@ -34,9 +39,22 @@ def test_deprecated_functions( def test_convert_same_unit() -> None: """Test conversion from any unit to same unit.""" - assert temperature_util.convert(2, TEMP_CELSIUS, TEMP_CELSIUS) == 2 - assert temperature_util.convert(3, TEMP_FAHRENHEIT, TEMP_FAHRENHEIT) == 3 - assert temperature_util.convert(4, TEMP_KELVIN, TEMP_KELVIN) == 4 + assert ( + temperature_util.convert( + 2, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS + ) + == 2 + ) + assert ( + temperature_util.convert( + 3, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.FAHRENHEIT + ) + == 3 + ) + assert ( + temperature_util.convert(4, UnitOfTemperature.KELVIN, UnitOfTemperature.KELVIN) + == 4 + ) def test_convert_invalid_unit() -> None: @@ -51,24 +69,26 @@ def test_convert_invalid_unit() -> None: def test_convert_nonnumeric_value() -> None: """Test exception is thrown for nonnumeric type.""" with pytest.raises(TypeError): - temperature_util.convert("a", TEMP_CELSIUS, TEMP_FAHRENHEIT) + temperature_util.convert( + "a", UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + ) def test_convert_from_celsius() -> None: """Test conversion from C to other units.""" celsius = 100 assert temperature_util.convert( - celsius, TEMP_CELSIUS, TEMP_FAHRENHEIT + celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ) == pytest.approx(212.0) assert temperature_util.convert( - celsius, TEMP_CELSIUS, TEMP_KELVIN + celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN ) == pytest.approx(373.15) # Interval assert temperature_util.convert( - celsius, TEMP_CELSIUS, TEMP_FAHRENHEIT, True + celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT, True ) == pytest.approx(180.0) assert temperature_util.convert( - celsius, TEMP_CELSIUS, TEMP_KELVIN, True + celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN, True ) == pytest.approx(100) @@ -76,33 +96,33 @@ def test_convert_from_fahrenheit() -> None: """Test conversion from F to other units.""" fahrenheit = 100 assert temperature_util.convert( - fahrenheit, TEMP_FAHRENHEIT, TEMP_CELSIUS + fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS ) == pytest.approx(37.77777777777778) assert temperature_util.convert( - fahrenheit, TEMP_FAHRENHEIT, TEMP_KELVIN + fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.KELVIN ) == pytest.approx(310.92777777777775) # Interval assert temperature_util.convert( - fahrenheit, TEMP_FAHRENHEIT, TEMP_CELSIUS, True + fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, True ) == pytest.approx(55.55555555555556) assert temperature_util.convert( - fahrenheit, TEMP_FAHRENHEIT, TEMP_KELVIN, True + fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.KELVIN, True ) == pytest.approx(55.55555555555556) def test_convert_from_kelvin() -> None: """Test conversion from K to other units.""" kelvin = 100 - assert temperature_util.convert(kelvin, TEMP_KELVIN, TEMP_CELSIUS) == pytest.approx( - -173.15 - ) assert temperature_util.convert( - kelvin, TEMP_KELVIN, TEMP_FAHRENHEIT + kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.CELSIUS + ) == pytest.approx(-173.15) + assert temperature_util.convert( + kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.FAHRENHEIT ) == pytest.approx(-279.66999999999996) # Interval assert temperature_util.convert( - kelvin, TEMP_KELVIN, TEMP_FAHRENHEIT, True + kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.FAHRENHEIT, True ) == pytest.approx(180.0) assert temperature_util.convert( - kelvin, TEMP_KELVIN, TEMP_KELVIN, True + kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.KELVIN, True ) == pytest.approx(100) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index bd99889234f..4f60c5836b5 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -354,8 +354,6 @@ def load_yaml(fname, string, secrets=None): class TestSecrets(unittest.TestCase): """Test the secrets parameter in the yaml utility.""" - # pylint: disable=invalid-name - def setUp(self): """Create & load secrets file.""" config_dir = get_test_config_dir()